7 Commits

Author SHA1 Message Date
naomi b604a4aa5c release: v0.1.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m7s
CI / Lint, Build & Test (push) Successful in 1m8s
2026-03-08 20:23:22 -07:00
hikari e10eabc8b5 fix: save character name correctly and show story on character sheet
CI / Lint, Build & Test (push) Successful in 1m9s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
- Load route syncs characterName from Player record so profile updates
  are reflected immediately on next load
- Save route preserves Player record's characterName so auto-saves
  cannot overwrite profile updates
- Public profile response now includes completedChapters
- Character sheet panel displays completed story chapters with outcome
- Removed stale CSS for old achievement/codex toast classes
2026-03-08 20:19:40 -07:00
hikari c3d79e0c11 feat: add third-person choice descriptions to public character sheet
CI / Lint, Build & Test (push) Failing after 57s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m15s
Each story choice now has a concise third-person description used on
the public character page, keeping narrative spoilers out of the
profile view whilst still conveying the character's path.
2026-03-08 20:15:26 -07:00
hikari 6e2cb45553 fix: delay boss lore toasts until battle animation reveals result
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:18:46 -07:00
hikari 5a065998b6 fix: delay boss notifications until reveal and animate hp bar colours
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m5s
- Move bossVictory sound and notification from gameContext into BattleModal,
  fired at the 5.2s reveal timeout so the animation plays before the spoiler
- Replace CSS width transition with a setInterval tick (50ms steps over 5s)
  so bossHpPercent and partyHpPercent update incrementally during the animation
- Both bars now use a shared getHpColour helper: green >50%, yellow 25-50%,
  red <25%, causing colour to shift naturally as the bar visually drains
2026-03-08 19:07:04 -07:00
hikari f9c925b9fc feat: unify toast styles and add quest/milestone toast notifications
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m5s
- Merge .codex-toast and .achievement-toast into a single .game-toast class
- Fix storyToast inner class names and replace <button> wrapper with <div>
- Add QuestCompleteToast and QuestFailedToast components
- Add MilestoneToast for prestige, transcendence, and apotheosis events
- Move shared toast container to gameLayout so all toasts stack in one column
- Wire quest detection in GameContext to store full Quest objects for toast names
- Trigger prestige toast from both auto-prestige and manual prestige panel
2026-03-08 18:47:42 -07:00
hikari 290c06de83 fix: correct combat power calculation in quest panel
CI / Lint, Build & Test (push) Failing after 49s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m18s
2026-03-08 16:02:49 -07:00
23 changed files with 892 additions and 305 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+21
View File
@@ -747,6 +747,14 @@ gameRouter.get("/load", async(context) => {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
const state = rawState as GameState; const state = rawState as GameState;
/*
* Always sync character name from the Player record — the profile update route
* writes to Player.characterName directly, bypassing the game state blob.
*/
if (playerRecord !== null) {
state.player.characterName = playerRecord.characterName;
}
const now = Date.now(); const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds } const { offlineGold, offlineEssence, offlineSeconds }
@@ -933,6 +941,19 @@ gameRouter.post("/save", async(context) => {
player: { ...stateToSave.player, lastSavedAt: now }, player: { ...stateToSave.player, lastSavedAt: now },
}; };
/*
* Preserve the Player record's character name so that profile updates are not
* overwritten by the next auto-save (profile PUT writes to Player, not the blob).
*/
stateToSave = {
...stateToSave,
player: {
...stateToSave.player,
characterName:
playerRecord?.characterName ?? stateToSave.player.characterName,
},
};
/* /*
* Recompute companion unlocks server-side using DB-authoritative player lifetime stats. * Recompute companion unlocks server-side using DB-authoritative player lifetime stats.
* This prevents clients from claiming companions they haven't legitimately unlocked. * This prevents clients from claiming companions they haven't legitimately unlocked.
+4
View File
@@ -5,6 +5,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Route handlers require many steps */ /* eslint-disable max-lines-per-function -- Route handlers require many steps */
/* eslint-disable max-statements -- Route handlers require many steps */
/* eslint-disable complexity -- Route handlers have inherent complexity */ /* eslint-disable complexity -- Route handlers have inherent complexity */
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */ /* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */ /* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
@@ -142,6 +143,8 @@ profileRouter.get("/:discordId", async(context) => {
}; };
}); });
const completedChapters = state?.story?.completedChapters ?? [];
return context.json({ return context.json({
achievementsUnlocked: achievementsUnlocked, achievementsUnlocked: achievementsUnlocked,
activeTitle: player.activeTitle, activeTitle: player.activeTitle,
@@ -153,6 +156,7 @@ profileRouter.get("/:discordId", async(context) => {
characterClass: player.characterClass, characterClass: player.characterClass,
characterName: player.characterName, characterName: player.characterName,
characterRace: player.characterRace ?? "", characterRace: player.characterRace ?? "",
completedChapters: completedChapters,
createdAt: player.createdAt, createdAt: player.createdAt,
currentRunClicks: state?.player.totalClicks ?? 0, currentRunClicks: state?.player.totalClicks ?? 0,
currentRunGold: state?.player.totalGoldEarned ?? 0, currentRunGold: state?.player.totalGoldEarned ?? 0,
+10
View File
@@ -233,6 +233,16 @@ describe("game route", () => {
expect(body.savedAt).toBeGreaterThan(0); expect(body.savedAt).toBeGreaterThan(0);
}); });
it("falls back to state characterName when playerRecord is null", async () => {
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const state = makeState();
const res = await save({ state });
expect(res.status).toBe(200);
});
it("validates and sanitizes state when previous record exists", async () => { it("validates and sanitizes state when previous record exists", async () => {
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } }); const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } }); const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
+18
View File
@@ -181,6 +181,24 @@ describe("profile route", () => {
const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id"); const unknown = body.unlockedTitles.find((t) => t.id === "unknown_title_id");
expect(unknown?.name).toBe("unknown_title_id"); expect(unknown?.name).toBe("unknown_title_id");
}); });
it("includes completed story chapters in profile response", async () => {
const state = makeState({
story: {
unlockedChapterIds: [ "boss_troll_king" ],
completedChapters: [ { chapterId: "boss_troll_king", choiceId: "fight" } ],
},
});
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
const res = await app.fetch(new Request(`http://localhost/profile/${DISCORD_ID}`));
expect(res.status).toBe(200);
const body = await res.json() as {
completedChapters: Array<{ chapterId: string; choiceId: string }>;
};
expect(body.completedChapters).toHaveLength(1);
expect(body.completedChapters[0]).toMatchObject({ chapterId: "boss_troll_king", choiceId: "fight" });
});
}); });
describe("PUT /", () => { describe("PUT /", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -41,7 +41,7 @@ const ToastItem = ({
const crystals = achievement.reward?.crystals; const crystals = achievement.reward?.crystals;
return ( return (
<div className="achievement-toast" onClick={handleClick}> <div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{achievement.icon}</span> <span className="toast-icon">{achievement.icon}</span>
<div className="toast-content"> <div className="toast-content">
<span className="toast-label">{"Achievement Unlocked!"}</span> <span className="toast-label">{"Achievement Unlocked!"}</span>
@@ -70,7 +70,7 @@ const AchievementToast = (): JSX.Element | null => {
} }
return ( return (
<div className="achievement-toast-container"> <>
{pendingAchievements.map((achievement) => { {pendingAchievements.map((achievement) => {
return ( return (
<ToastItem <ToastItem
@@ -80,7 +80,7 @@ const AchievementToast = (): JSX.Element | null => {
/> />
); );
})} })}
</div> </>
); );
}; };
+80 -25
View File
@@ -8,6 +8,8 @@
/* eslint-disable complexity -- Battle result display requires many conditional paths */ /* eslint-disable complexity -- Battle result display requires many conditional paths */
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import { type BattleResult, useGame } from "../../context/gameContext.js"; import { type BattleResult, useGame } from "../../context/gameContext.js";
import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js";
/** /**
* Converts HP values to a percentage for display. * Converts HP values to a percentage for display.
@@ -23,6 +25,22 @@ const toHpPercent = (current: number, maximum: number): number => {
return scaled / maximum; return scaled / maximum;
}; };
/**
* Returns a colour hex string based on the HP percentage.
* Green above 50%, yellow 2550%, red below 25%.
* @param percent - Current HP as a percentage (0100).
* @returns A hex colour string.
*/
const getHpColour = (percent: number): string => {
if (percent > 50) {
return "#27ae60";
}
if (percent > 25) {
return "#f39c12";
}
return "#e74c3c";
};
interface BattleModalProperties { interface BattleModalProperties {
readonly battle: BattleResult; readonly battle: BattleResult;
readonly onDismiss: ()=> void; readonly onDismiss: ()=> void;
@@ -40,12 +58,16 @@ const BattleModal = ({
onDismiss, onDismiss,
}: BattleModalProperties): JSX.Element => { }: BattleModalProperties): JSX.Element => {
const { result, bossName } = battle; const { result, bossName } = battle;
const { formatNumber } = useGame(); const {
enableNotifications,
enableSounds,
flushBossLoreToasts,
formatNumber,
} = useGame();
const [ phase, setPhase ] = useState<"animating" | "result">("animating"); const [ phase, setPhase ] = useState<"animating" | "result">("animating");
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp); const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
const partyStartPercent = 100;
const bossEndPercent = toHpPercent( const bossEndPercent = toHpPercent(
result.bossHpAtBattleEnd, result.bossHpAtBattleEnd,
@@ -57,37 +79,72 @@ const BattleModal = ({
); );
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent); const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent); const [ partyHpPercent, setPartyHpPercent ] = useState(100);
useEffect(() => { useEffect(() => {
const startAnimation = setTimeout(() => { const animationDurationMs = 5000;
setBossHpPercent(bossEndPercent); const intervalMs = 50;
setPartyHpPercent(partyEndPercent); const totalSteps = animationDurationMs / intervalMs;
const bossHpRange = bossEndPercent - bossStartPercent;
const bossDelta = bossHpRange / totalSteps;
const partyHpRange = partyEndPercent - 100;
const partyDelta = partyHpRange / totalSteps;
let currentStep = 0;
// eslint-disable-next-line @typescript-eslint/init-declarations -- assigned inside timeout
let intervalId: ReturnType<typeof setInterval> | undefined;
const tick = (): void => {
currentStep = currentStep + 1;
if (currentStep >= totalSteps) {
setBossHpPercent(bossEndPercent);
setPartyHpPercent(partyEndPercent);
clearInterval(intervalId);
} else {
const bossStep = bossDelta * currentStep;
setBossHpPercent(bossStartPercent + bossStep);
const partyStep = partyDelta * currentStep;
setPartyHpPercent(100 + partyStep);
}
};
const startTimeout = setTimeout(() => {
intervalId = setInterval(tick, intervalMs);
}, 200); }, 200);
const revealResult = setTimeout(() => { const revealTimeout = setTimeout(() => {
setPhase("result"); setPhase("result");
flushBossLoreToasts();
if (result.won) {
if (enableSounds) {
playSound("bossVictory");
}
if (enableNotifications) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${bossName}!`);
}
}
}, 5200); }, 5200);
return (): void => { return (): void => {
clearTimeout(startAnimation); clearTimeout(startTimeout);
clearTimeout(revealResult); clearTimeout(revealTimeout);
clearInterval(intervalId);
}; };
}, [ bossEndPercent, partyEndPercent ]); }, [
bossEndPercent,
bossName,
bossStartPercent,
enableNotifications,
enableSounds,
flushBossLoreToasts,
partyEndPercent,
result.won,
]);
let bossHpBarColour = "#c0392b"; const bossHpBarColour = getHpColour(bossHpPercent);
if (bossHpPercent > 50) { const partyHpBarColour = getHpColour(partyHpPercent);
bossHpBarColour = "#e74c3c";
} else if (bossHpPercent > 25) {
bossHpBarColour = "#e67e22";
}
let partyHpBarColour = "#e74c3c";
if (partyHpPercent > 50) {
partyHpBarColour = "#27ae60";
} else if (partyHpPercent > 25) {
partyHpBarColour = "#f39c12";
}
return ( return (
<div className="modal-overlay"> <div className="modal-overlay">
@@ -120,7 +177,6 @@ const BattleModal = ({
className="hp-bar-fill" className="hp-bar-fill"
style={{ style={{
backgroundColor: bossHpBarColour, backgroundColor: bossHpBarColour,
transition: "width 5s ease-in-out",
width: `${bossHpPercent.toFixed(1)}%`, width: `${bossHpPercent.toFixed(1)}%`,
}} }}
/> />
@@ -141,7 +197,6 @@ const BattleModal = ({
className="hp-bar-fill party-hp" className="hp-bar-fill party-hp"
style={{ style={{
backgroundColor: partyHpBarColour, backgroundColor: partyHpBarColour,
transition: "width 5s ease-in-out",
width: `${partyHpPercent.toFixed(1)}%`, width: `${partyHpPercent.toFixed(1)}%`,
}} }}
/> />
+44 -5
View File
@@ -5,13 +5,15 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable max-lines -- Story section adds lines beyond the file limit */
/* eslint-disable complexity -- Many conditional render paths for optional fields */ /* eslint-disable complexity -- Many conditional render paths for optional fields */
import { type JSX, useEffect, useState } from "react"; import {
import type { STORY_CHAPTERS,
EquipmentBonus, type EquipmentBonus,
EquipmentType, type EquipmentType,
PublicProfileResponse, type PublicProfileResponse,
} from "@elysium/types"; } from "@elysium/types";
import { type JSX, useEffect, useState } from "react";
interface CharacterPageProperties { interface CharacterPageProperties {
readonly discordId: string; readonly discordId: string;
@@ -269,6 +271,43 @@ const CharacterPage = ({ discordId }: CharacterPageProperties): JSX.Element => {
</div> </div>
} }
{profile.completedChapters.length === 0
? null
: <div className="character-page-section">
<h2 className="character-page-section-title">{"📖 Story"}</h2>
{profile.completedChapters.map((completion) => {
const chapter = STORY_CHAPTERS.find((candidate) => {
return candidate.id === completion.chapterId;
});
if (chapter === undefined) {
return null;
}
const choice = chapter.choices.find((candidate) => {
return candidate.id === completion.choiceId;
});
if (choice === undefined) {
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>
<p className="character-sheet-story-outcome">
{choice.description}
</p>
</div>
);
})}
</div>
}
<div className="character-page-divider" /> <div className="character-page-divider" />
<p className="character-page-player-line"> <p className="character-page-player-line">
@@ -657,6 +657,15 @@ const CharacterSheetPanel = (): JSX.Element => {
if (choice === undefined) { if (choice === undefined) {
return null; return null;
} }
const characterName
= player?.characterName === ""
|| player?.characterName === undefined
? "the guild leader"
: player.characterName;
const outcome = choice.outcome.replaceAll(
"{characterName}",
characterName,
);
return ( return (
<div <div
className="character-sheet-story-entry" className="character-sheet-story-entry"
@@ -668,6 +677,7 @@ const CharacterSheetPanel = (): JSX.Element => {
<span className="character-sheet-story-choice"> <span className="character-sheet-story-choice">
{choice.label} {choice.label}
</span> </span>
<p className="character-sheet-story-outcome">{outcome}</p>
</div> </div>
); );
})} })}
+3 -3
View File
@@ -47,7 +47,7 @@ const CodexToastItem = ({
} }
return ( return (
<div className="codex-toast" onClick={handleClick}> <div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{"📖"}</span> <span className="toast-icon">{"📖"}</span>
<div className="toast-content"> <div className="toast-content">
<span className="toast-label">{"✨ Lore Unlocked!"}</span> <span className="toast-label">{"✨ Lore Unlocked!"}</span>
@@ -70,13 +70,13 @@ const CodexToast = (): JSX.Element | null => {
} }
return ( return (
<div className="achievement-toast-container"> <>
{pendingEntryIds.map((id) => { {pendingEntryIds.map((id) => {
return ( return (
<CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} /> <CodexToastItem entryId={id} key={id} onDismiss={dismissCodexEntry} />
); );
})} })}
</div> </>
); );
}; };
+10 -3
View File
@@ -27,10 +27,12 @@ import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js"; import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js"; import { ExplorationPanel } from "./explorationPanel.js";
import { LoginBonusModal } from "./loginBonusModal.js"; import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js"; import { OfflineModal } from "./offlineModal.js";
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js"; import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
import { PrestigePanel } from "./prestigePanel.js"; import { PrestigePanel } from "./prestigePanel.js";
import { QuestPanel } from "./questPanel.js"; import { QuestPanel } from "./questPanel.js";
import { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
import { StatisticsPanel } from "./statisticsPanel.js"; import { StatisticsPanel } from "./statisticsPanel.js";
import { StoryPanel } from "./storyPanel.js"; import { StoryPanel } from "./storyPanel.js";
import { StoryToast } from "./storyToast.js"; import { StoryToast } from "./storyToast.js";
@@ -164,9 +166,14 @@ const GameLayout = (): JSX.Element => {
{schemaOutdated && !dismissedOutdatedWarning {schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} /> ? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null} : null}
<AchievementToast /> <div className="achievement-toast-container">
<CodexToast /> <AchievementToast />
<StoryToast /> <CodexToast />
<MilestoneToast />
<QuestCompleteToast />
<QuestFailedToast />
<StoryToast />
</div>
{loginBonus === null {loginBonus === null
? null ? null
: <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} /> : <LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
@@ -0,0 +1,96 @@
/**
* @file Milestone toast notification component for prestige, transcendence, and apotheosis.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */
import { type JSX, useEffect } from "react";
import { useGame } from "../../context/gameContext.js";
interface MilestoneToastItemProperties {
readonly icon: string;
readonly label: string;
readonly onDismiss: ()=> void;
}
/**
* Renders a single milestone toast notification.
* @param props - The toast item properties.
* @param props.icon - The emoji icon.
* @param props.label - The label text.
* @param props.onDismiss - Callback to dismiss the toast.
* @returns The JSX element.
*/
const MilestoneToastItem = ({
icon,
label,
onDismiss,
}: MilestoneToastItemProperties): JSX.Element => {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss();
}, 4000);
return (): void => {
clearTimeout(timer);
};
}, [ onDismiss ]);
return (
<div className="game-toast" onClick={onDismiss}>
<span className="toast-icon">{icon}</span>
<div className="toast-content">
<span className="toast-label">{label}</span>
</div>
</div>
);
};
/**
* Renders all milestone toasts (prestige, transcendence, apotheosis).
* @returns The JSX element or null if no milestone toasts are pending.
*/
const MilestoneToast = (): JSX.Element | null => {
const {
showPrestigeToast,
showTranscendenceToast,
showApotheosisToast,
dismissPrestigeToast,
dismissTranscendenceToast,
dismissApotheosisToast,
} = useGame();
const hasAny
= showPrestigeToast || showTranscendenceToast || showApotheosisToast;
if (!hasAny) {
return null;
}
return (
<>
{showPrestigeToast
? <MilestoneToastItem
icon={"⭐"}
label={"⭐ Prestige!"}
onDismiss={dismissPrestigeToast}
/>
: null}
{showTranscendenceToast
? <MilestoneToastItem
icon={"🌌"}
label={"🌌 Transcendence!"}
onDismiss={dismissTranscendenceToast}
/>
: null}
{showApotheosisToast
? <MilestoneToastItem
icon={"✨"}
label={"✨ Apotheosis!"}
onDismiss={dismissApotheosisToast}
/>
: null}
</>
);
};
export { MilestoneToast };
@@ -89,6 +89,7 @@ const PrestigePanel = (): JSX.Element => {
enableNotifications, enableNotifications,
enableSounds, enableSounds,
toggleAutoPrestige, toggleAutoPrestige,
triggerPrestigeToast,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{ const [ result, setResult ] = useState<{
@@ -128,6 +129,7 @@ const PrestigePanel = (): JSX.Element => {
milestoneRunestones: data.milestoneRunestones, milestoneRunestones: data.milestoneRunestones,
runestones: data.runestones, runestones: data.runestones,
}); });
triggerPrestigeToast();
if (enableSounds) { if (enableSounds) {
playSound("prestige"); playSound("prestige");
} }
+5 -5
View File
@@ -190,11 +190,11 @@ const QuestPanel = (): JSX.Element => {
} }
const { adventurers, autoQuest, quests, zones } = state; const { adventurers, autoQuest, quests, zones } = state;
// eslint-disable-next-line unicorn/no-array-reduce -- Need the total! let partyCombatPower = 0;
const partyCombatPower = adventurers.reduce((total, adventurer) => { for (const adventurer of adventurers) {
const power = total + adventurer.combatPower; const contribution = adventurer.combatPower * adventurer.count;
return power * adventurer.count; partyCombatPower = partyCombatPower + contribution;
}, 0); }
const zoneQuests = quests.filter(({ zoneId }) => { const zoneQuests = quests.filter(({ zoneId }) => {
return zoneId === activeZoneId; return zoneId === activeZoneId;
}); });
+113
View File
@@ -0,0 +1,113 @@
/**
* @file Quest toast notification component for completed and failed quests.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */
import { type JSX, useEffect } from "react";
import { useGame } from "../../context/gameContext.js";
import type { Quest } from "@elysium/types";
interface QuestToastItemProperties {
readonly quest: Quest;
readonly onDismiss: (id: string)=> void;
// eslint-disable-next-line react/require-default-props -- Default value set in destructuring
readonly isFailure?: boolean;
}
/**
* Renders a single quest toast notification.
* @param props - The toast item properties.
* @param props.quest - The quest to display.
* @param props.onDismiss - Callback to dismiss the toast.
* @param props.isFailure - Whether this is a failure toast.
* @returns The JSX element.
*/
const QuestToastItem = ({
quest,
onDismiss,
isFailure = false,
}: QuestToastItemProperties): JSX.Element => {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(quest.id);
}, 4000);
return (): void => {
clearTimeout(timer);
};
}, [ quest.id, onDismiss ]);
function handleClick(): void {
onDismiss(quest.id);
}
return (
<div className="game-toast" onClick={handleClick}>
<span className="toast-icon">{isFailure
? "💀"
: "📜"}</span>
<div className="toast-content">
<span className="toast-label">{isFailure
? "Quest Failed!"
: "✨ Quest Complete!"}</span>
<span className="toast-name">{quest.name}</span>
</div>
</div>
);
};
/**
* Renders the quest complete toast container.
* @returns The JSX element or null if there are no pending quest toasts.
*/
const QuestCompleteToast = (): JSX.Element | null => {
const { completedQuestToasts, dismissCompletedQuest } = useGame();
if (completedQuestToasts.length === 0) {
return null;
}
return (
<>
{completedQuestToasts.map((quest) => {
return (
<QuestToastItem
key={quest.id}
onDismiss={dismissCompletedQuest}
quest={quest}
/>
);
})}
</>
);
};
/**
* Renders the quest failed toast container.
* @returns The JSX element or null if there are no pending failure toasts.
*/
const QuestFailedToast = (): JSX.Element | null => {
const { failedQuestToasts, dismissFailedQuest } = useGame();
if (failedQuestToasts.length === 0) {
return null;
}
return (
<>
{failedQuestToasts.map((quest) => {
return (
<QuestToastItem
isFailure={true}
key={quest.id}
onDismiss={dismissFailedQuest}
quest={quest}
/>
);
})}
</>
);
};
export { QuestCompleteToast, QuestFailedToast };
+8 -8
View File
@@ -45,13 +45,13 @@ const StoryToastItem = ({
} }
return ( return (
<button className="achievement-toast" onClick={handleClick} type="button"> <div className="game-toast" onClick={handleClick}>
<span className="achievement-toast-icon">{"📖"}</span> <span className="toast-icon">{"📖"}</span>
<div className="achievement-toast-content"> <div className="toast-content">
<span className="achievement-toast-label">{"✨ New Chapter!"}</span> <span className="toast-label">{"✨ New Chapter!"}</span>
<span className="achievement-toast-name">{chapter.title}</span> <span className="toast-name">{chapter.title}</span>
</div> </div>
</button> </div>
); );
}; };
@@ -65,11 +65,11 @@ const StoryToast = (): JSX.Element | null => {
return null; return null;
} }
return ( return (
<div className="achievement-toast-container"> <>
{pendingChapterIds.map((id) => { {pendingChapterIds.map((id) => {
return <StoryToastItem chapterId={id} key={id} />; return <StoryToastItem chapterId={id} key={id} />;
})} })}
</div> </>
); );
}; };
+177 -32
View File
@@ -20,6 +20,7 @@ import {
type GameState, type GameState,
type LoginBonusResult, type LoginBonusResult,
type NumberFormat, type NumberFormat,
type Quest,
type TranscendenceResponse, type TranscendenceResponse,
isStoryChapterUnlocked, isStoryChapterUnlocked,
} from "@elysium/types"; } from "@elysium/types";
@@ -334,6 +335,61 @@ interface GameContextValue {
*/ */
dismissAchievement: (id: string)=> void; dismissAchievement: (id: string)=> void;
/**
* Queue of newly completed quests (for toast notifications).
*/
completedQuestToasts: Array<Quest>;
/**
* Remove a quest from the completed toast queue.
*/
dismissCompletedQuest: (id: string)=> void;
/**
* Queue of newly failed quests (for toast notifications).
*/
failedQuestToasts: Array<Quest>;
/**
* Remove a quest from the failed toast queue.
*/
dismissFailedQuest: (id: string)=> void;
/**
* Whether the prestige milestone toast is currently showing.
*/
showPrestigeToast: boolean;
/**
* Trigger the prestige milestone toast (called from prestigePanel on manual prestige).
*/
triggerPrestigeToast: ()=> void;
/**
* Dismiss the prestige milestone toast.
*/
dismissPrestigeToast: ()=> void;
/**
* Whether the transcendence milestone toast is currently showing.
*/
showTranscendenceToast: boolean;
/**
* Dismiss the transcendence milestone toast.
*/
dismissTranscendenceToast: ()=> void;
/**
* Whether the apotheosis milestone toast is currently showing.
*/
showApotheosisToast: boolean;
/**
* Dismiss the apotheosis milestone toast.
*/
dismissApotheosisToast: ()=> void;
/** /**
* The player's chosen number display format. * The player's chosen number display format.
*/ */
@@ -399,6 +455,11 @@ interface GameContextValue {
*/ */
dismissCodexEntry: (id: string)=> void; dismissCodexEntry: (id: string)=> void;
/**
* Flush pending boss lore codex toasts — call after the battle animation reveals the result.
*/
flushBossLoreToasts: ()=> void;
/** /**
* Perform a transcendence — nuclear reset, earning echoes. * Perform a transcendence — nuclear reset, earning echoes.
*/ */
@@ -514,6 +575,15 @@ export const GameProvider = ({
const [ unlockedAchievements, setUnlockedAchievements ] = useState< const [ unlockedAchievements, setUnlockedAchievements ] = useState<
Array<Achievement> Array<Achievement>
>([]); >([]);
const [ completedQuestToasts, setCompletedQuestToasts ] = useState<
Array<Quest>
>([]);
const [ failedQuestToasts, setFailedQuestToasts ] = useState<Array<Quest>>(
[],
);
const [ showPrestigeToast, setShowPrestigeToast ] = useState(false);
const [ showTranscendenceToast, setShowTranscendenceToast ] = useState(false);
const [ showApotheosisToast, setShowApotheosisToast ] = useState(false);
const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null); const [ lastSavedAt, setLastSavedAt ] = useState<number | null>(null);
const [ isSyncing, setIsSyncing ] = useState(false); const [ isSyncing, setIsSyncing ] = useState(false);
const [ syncError, setSyncError ] = useState<string | null>(null); const [ syncError, setSyncError ] = useState<string | null>(null);
@@ -530,8 +600,8 @@ export const GameProvider = ({
const isSyncingReference = useRef(false); const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null); const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]); const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
const newlyCompletedQuestsCountReference = useRef(0); const newlyCompletedQuestsReference = useRef<Array<Quest>>([]);
const newlyFailedQuestsCountReference = useRef(0); const newlyFailedQuestsReference = useRef<Array<Quest>>([]);
const signatureReference = useRef<string | null>( const signatureReference = useRef<string | null>(
localStorage.getItem("elysium_save_signature"), localStorage.getItem("elysium_save_signature"),
); );
@@ -548,6 +618,7 @@ export const GameProvider = ({
Array<string> Array<string>
>([]); >([]);
const codexProcessedReference = useRef<Set<string>>(new Set()); const codexProcessedReference = useRef<Set<string>>(new Set());
const pendingBossCodexIdsReference = useRef<Array<string>>([]);
const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState< const [ unlockedStoryChapterIds, setUnlockedStoryChapterIds ] = useState<
Array<string> Array<string>
>([]); >([]);
@@ -815,12 +886,30 @@ export const GameProvider = ({
}; };
}); });
if (!isFirstRun) { if (!isFirstRun) {
setUnlockedCodexEntryIds((previous) => { const bossIds = addedIds.filter((id) => {
return [ ...previous, ...addedIds ]; return id.startsWith("boss_");
}); });
const otherIds = addedIds.filter((id) => {
return !id.startsWith("boss_");
});
if (bossIds.length > 0) {
if (battleResult === null) {
otherIds.push(...bossIds);
} else {
pendingBossCodexIdsReference.current = [
...pendingBossCodexIdsReference.current,
...bossIds,
];
}
}
if (otherIds.length > 0) {
setUnlockedCodexEntryIds((previous) => {
return [ ...previous, ...otherIds ];
});
}
} }
} }
}, [ state ]); }, [ battleResult, state ]);
// Detect newly unlocked story chapters // Detect newly unlocked story chapters
useEffect(() => { useEffect(() => {
@@ -949,17 +1038,17 @@ export const GameProvider = ({
); );
// Detect newly completed quests // Detect newly completed quests
newlyCompletedQuestsCountReference.current = next.quests.filter( newlyCompletedQuestsReference.current = next.quests.filter(
(q, index) => { (q, index) => {
return ( return (
previous.quests[index]?.status === "active" previous.quests[index]?.status === "active"
&& q.status === "completed" && q.status === "completed"
); );
}, },
).length; );
// Detect newly failed quests // Detect newly failed quests
newlyFailedQuestsCountReference.current = next.quests.filter( newlyFailedQuestsReference.current = next.quests.filter(
(q, index) => { (q, index) => {
const previousFailedAt = previous.quests[index]?.lastFailedAt; const previousFailedAt = previous.quests[index]?.lastFailedAt;
return ( return (
@@ -967,7 +1056,7 @@ export const GameProvider = ({
&& q.lastFailedAt !== previousFailedAt && q.lastFailedAt !== previousFailedAt
); );
}, },
).length; );
return next; return next;
}); });
@@ -987,24 +1076,30 @@ export const GameProvider = ({
unlockedAchievementsReference.current = []; unlockedAchievementsReference.current = [];
} }
if (newlyCompletedQuestsCountReference.current > 0) { if (newlyCompletedQuestsReference.current.length > 0) {
setCompletedQuestToasts((previous) => {
return [ ...previous, ...newlyCompletedQuestsReference.current ];
});
if (enableSoundsReference.current) { if (enableSoundsReference.current) {
playSound("questCompleted"); playSound("questCompleted");
} }
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("📜 Quest Complete!", "A quest has been completed."); sendNotification("📜 Quest Complete!", "A quest has been completed.");
} }
newlyCompletedQuestsCountReference.current = 0; newlyCompletedQuestsReference.current = [];
} }
if (newlyFailedQuestsCountReference.current > 0) { if (newlyFailedQuestsReference.current.length > 0) {
setFailedQuestToasts((previous) => {
return [ ...previous, ...newlyFailedQuestsReference.current ];
});
if (enableSoundsReference.current) { if (enableSoundsReference.current) {
playSound("questFailed"); playSound("questFailed");
} }
if (enableNotificationsReference.current) { if (enableNotificationsReference.current) {
sendNotification("💀 Quest Failed!", "A quest has failed."); sendNotification("💀 Quest Failed!", "A quest has failed.");
} }
newlyFailedQuestsCountReference.current = 0; newlyFailedQuestsReference.current = [];
} }
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) // Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
@@ -1054,6 +1149,7 @@ export const GameProvider = ({
isAutoPrestigingReference.current = true; isAutoPrestigingReference.current = true;
void prestigeApi({}). void prestigeApi({}).
then(async() => { then(async() => {
setShowPrestigeToast(true);
if (enableSoundsReference.current) { if (enableSoundsReference.current) {
playSound("prestige"); playSound("prestige");
} }
@@ -1103,17 +1199,6 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
setBattleResult({ bossName, result }); setBattleResult({ bossName, result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification(
"⚔️ Boss Defeated!",
`You defeated ${bossName}!`,
);
}
}
}). }).
catch(() => { catch(() => {
@@ -1443,6 +1528,7 @@ export const GameProvider = ({
const transcend = useCallback(async() => { const transcend = useCallback(async() => {
const result = await transcendApi({}); const result = await transcendApi({});
setShowTranscendenceToast(true);
if (enableSoundsReference.current) { if (enableSoundsReference.current) {
playSound("transcendence"); playSound("transcendence");
} }
@@ -1455,6 +1541,7 @@ export const GameProvider = ({
const apotheosis = useCallback(async() => { const apotheosis = useCallback(async() => {
const result = await achieveApotheosisApi({}); const result = await achieveApotheosisApi({});
setShowApotheosisToast(true);
if (enableSoundsReference.current) { if (enableSoundsReference.current) {
playSound("apotheosis"); playSound("apotheosis");
} }
@@ -1711,14 +1798,6 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
setBattleResult({ bossName: boss.name, result: result }); setBattleResult({ bossName: boss.name, result: result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${boss.name}!`);
}
}
} catch { } catch {
// Silently ignore — server errors shouldn't crash the UI // Silently ignore — server errors shouldn't crash the UI
} }
@@ -1733,6 +1812,38 @@ export const GameProvider = ({
setBattleResult(null); setBattleResult(null);
}, []); }, []);
const dismissCompletedQuest = useCallback((id: string) => {
setCompletedQuestToasts((previous) => {
return previous.filter((q) => {
return q.id !== id;
});
});
}, []);
const dismissFailedQuest = useCallback((id: string) => {
setFailedQuestToasts((previous) => {
return previous.filter((q) => {
return q.id !== id;
});
});
}, []);
const triggerPrestigeToast = useCallback(() => {
setShowPrestigeToast(true);
}, []);
const dismissPrestigeToast = useCallback(() => {
setShowPrestigeToast(false);
}, []);
const dismissTranscendenceToast = useCallback(() => {
setShowTranscendenceToast(false);
}, []);
const dismissApotheosisToast = useCallback(() => {
setShowApotheosisToast(false);
}, []);
const dismissAchievement = useCallback((id: string) => { const dismissAchievement = useCallback((id: string) => {
setUnlockedAchievements((previous) => { setUnlockedAchievements((previous) => {
return previous.filter((a) => { return previous.filter((a) => {
@@ -1749,6 +1860,16 @@ export const GameProvider = ({
}); });
}, []); }, []);
const flushBossLoreToasts = useCallback(() => {
const pending = pendingBossCodexIdsReference.current;
if (pending.length > 0) {
pendingBossCodexIdsReference.current = [];
setUnlockedCodexEntryIds((previous) => {
return [ ...previous, ...pending ];
});
}
}, []);
const dismissStoryChapter = useCallback((id: string) => { const dismissStoryChapter = useCallback((id: string) => {
setUnlockedStoryChapterIds((previous) => { setUnlockedStoryChapterIds((previous) => {
return previous.filter((chapter) => { return previous.filter((chapter) => {
@@ -1829,18 +1950,26 @@ export const GameProvider = ({
challengeBoss, challengeBoss,
collectExploration, collectExploration,
completeChapter, completeChapter,
completedQuestToasts,
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
dismissAchievement, dismissAchievement,
dismissApotheosisToast,
dismissBattle, dismissBattle,
dismissCodexEntry, dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus, dismissLoginBonus,
dismissOfflineGold, dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter, dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
equipItem, equipItem,
error, error,
failedQuestToasts,
flushBossLoreToasts,
forceSync, forceSync,
formatNumber, formatNumber,
handleClick, handleClick,
@@ -1860,6 +1989,9 @@ export const GameProvider = ({
setEnableNotifications, setEnableNotifications,
setEnableSounds, setEnableSounds,
setNumberFormat, setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration, startExploration,
startQuest, startQuest,
state, state,
@@ -1868,6 +2000,7 @@ export const GameProvider = ({
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast,
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
@@ -1875,6 +2008,8 @@ export const GameProvider = ({
}, [ }, [
apotheosis, apotheosis,
battleResult, battleResult,
completedQuestToasts,
failedQuestToasts,
formatNumber, formatNumber,
buyAdventurer, buyAdventurer,
buyEchoUpgrade, buyEchoUpgrade,
@@ -1887,15 +2022,21 @@ export const GameProvider = ({
craftRecipe, craftRecipe,
currentSchemaVersion, currentSchemaVersion,
dismissAchievement, dismissAchievement,
dismissApotheosisToast,
dismissBattle, dismissBattle,
dismissCodexEntry, dismissCodexEntry,
dismissCompletedQuest,
dismissFailedQuest,
dismissLoginBonus, dismissLoginBonus,
dismissOfflineGold, dismissOfflineGold,
dismissPrestigeToast,
dismissStoryChapter, dismissStoryChapter,
dismissTranscendenceToast,
enableNotifications, enableNotifications,
enableSounds, enableSounds,
equipItem, equipItem,
error, error,
flushBossLoreToasts,
forceSync, forceSync,
handleClick, handleClick,
isLoading, isLoading,
@@ -1914,6 +2055,9 @@ export const GameProvider = ({
setEnableNotifications, setEnableNotifications,
setEnableSounds, setEnableSounds,
setNumberFormat, setNumberFormat,
showApotheosisToast,
showPrestigeToast,
showTranscendenceToast,
startExploration, startExploration,
startQuest, startQuest,
state, state,
@@ -1922,6 +2066,7 @@ export const GameProvider = ({
toggleAutoPrestige, toggleAutoPrestige,
toggleAutoQuest, toggleAutoQuest,
transcend, transcend,
triggerPrestigeToast,
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
+9 -16
View File
@@ -1432,20 +1432,6 @@ body {
z-index: 200; z-index: 200;
} }
.achievement-toast {
align-items: center;
animation: slide-in-right 0.35s ease-out;
background: var(--colour-surface);
border: 1px solid var(--colour-gold);
border-radius: var(--radius);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
cursor: pointer;
display: flex;
gap: 0.75rem;
max-width: 280px;
padding: 0.75rem 1rem;
}
.toast-icon { .toast-icon {
font-size: 1.5rem; font-size: 1.5rem;
flex-shrink: 0; flex-shrink: 0;
@@ -2481,8 +2467,8 @@ body {
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
} }
/* Codex toast — uses a different accent from achievement toast */ /* Unified game toast — essence-coloured border used by all in-game notifications */
.codex-toast { .game-toast {
align-items: center; align-items: center;
animation: slide-in-right 0.35s ease-out; animation: slide-in-right 0.35s ease-out;
background: var(--colour-surface); background: var(--colour-surface);
@@ -4400,3 +4386,10 @@ body {
font-size: 0.8rem; font-size: 0.8rem;
font-style: italic; font-style: italic;
} }
.character-sheet-story-outcome {
margin: 0;
color: var(--colour-muted);
font-size: 0.8rem;
line-height: 1.5;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+6
View File
@@ -12,6 +12,7 @@ import type {
import type { GameState } from "./gameState.js"; import type { GameState } from "./gameState.js";
import type { Player } from "./player.js"; import type { Player } from "./player.js";
import type { ProfileSettings } from "./profileSettings.js"; import type { ProfileSettings } from "./profileSettings.js";
import type { CompletedChapter } from "./story.js";
interface AuthResponse { interface AuthResponse {
token: string; token: string;
@@ -247,6 +248,11 @@ interface PublicProfileResponse {
rarity: EquipmentRarity; rarity: EquipmentRarity;
bonus: EquipmentBonus; bonus: EquipmentBonus;
}>; }>;
/**
* Story chapters the player has completed and their chosen outcomes.
*/
completedChapters: Array<CompletedChapter>;
} }
interface UpdateProfileRequest { interface UpdateProfileRequest {
+269 -201
View File
@@ -1,4 +1,5 @@
/* eslint-disable max-lines -- story data file necessarily exceeds line limit */ /* eslint-disable max-lines -- story data file necessarily exceeds line limit */
/* eslint-disable stylistic/max-len -- story descriptions are naturally long */
/** /**
* @file Story chapter types and data for the Elysium game. * @file Story chapter types and data for the Elysium game.
* @copyright nhcarrigan * @copyright nhcarrigan
@@ -9,9 +10,10 @@ import type { Boss } from "./boss.js";
import type { GameState } from "./gameState.js"; import type { GameState } from "./gameState.js";
interface StoryChoice { interface StoryChoice {
id: string; description: string;
label: string; id: string;
outcome: string; label: string;
outcome: string;
} }
interface StoryChapter { interface StoryChapter {
@@ -88,23 +90,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "resolve", description: "Accepted the map with quiet resolve, already looking east.",
label: "Accept the map with quiet resolve", id: "resolve",
outcome: `You folded the map carefully and tucked it away. Resolve was the only` label: "Accept the map with quiet resolve",
outcome: `You folded the map carefully and tucked it away. Resolve was the only`
+ ` currency you had in abundance. The cartographer watched you go and thought:` + ` currency you had in abundance. The cartographer watched you go and thought:`
+ ` this one has the look of someone who finishes things.`, + ` this one has the look of someone who finishes things.`,
}, },
{ {
id: "people", description: "Turned back to their people first — some leaders are built for their guild.",
label: "Return immediately to your people", id: "people",
outcome: `Your first thought was of your guild — of wounds to tend and rest` label: "Return immediately to your people",
outcome: `Your first thought was of your guild — of wounds to tend and rest`
+ ` hard-earned. The cartographer smiled at your back. Some leaders are built for` + ` hard-earned. The cartographer smiled at your back. Some leaders are built for`
+ ` glory; some are built for their people. You were becoming the latter.`, + ` glory; some are built for their people. You were becoming the latter.`,
}, },
{ {
id: "plan", description: "Studied the map in silence, already charting the next move.",
label: "Study it in silence, already planning", id: "plan",
outcome: `Your eyes moved across the map before she'd even finished speaking. The` label: "Study it in silence, already planning",
outcome: `Your eyes moved across the map before she'd even finished speaking. The`
+ ` forest had only been the first line of a much longer story. You were already` + ` forest had only been the first line of a much longer story. You were already`
+ ` writing the next.`, + ` writing the next.`,
}, },
@@ -129,24 +134,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "listen", description: "Stayed to hear the scholar's findings, filing every warning about what had ended the city.",
label: "Ask the scholar what she has learned", id: "listen",
outcome: `You stayed long enough to listen. The scholar was cautious with her theories` label: "Ask the scholar what she has learned",
outcome: `You stayed long enough to listen. The scholar was cautious with her theories`
+ ` but certain of one thing: the people who had built this place had been powerful,` + ` but certain of one thing: the people who had built this place had been powerful,`
+ ` and their end had come from somewhere far beyond the Vale. You filed that` + ` and their end had come from somewhere far beyond the Vale. You filed that`
+ ` knowledge away like a sharp blade.`, + ` knowledge away like a sharp blade.`,
}, },
{ {
id: "claim", description: "Claimed the ancient hall as a waystation — filling old bones with new purpose.",
label: "Claim the hall as a guild waystation", id: "claim",
outcome: `The ruins needed purpose more than they needed silence. Your guild cleared` label: "Claim the hall as a guild waystation",
outcome: `The ruins needed purpose more than they needed silence. Your guild cleared`
+ ` rubble, shored up walls, and lit fires in hearths that hadn't been warm in an` + ` rubble, shored up walls, and lit fires in hearths that hadn't been warm in an`
+ ` age. Whatever had ended the people here, it would not end you.`, + ` age. Whatever had ended the people here, it would not end you.`,
}, },
{ {
id: "press", description: "Marked the ruin on the chart and pressed on. History could wait.",
label: "Mark it on your chart and press on", id: "press",
outcome: `There would be time for history later. You marked the ruin on your chart` label: "Mark it on your chart and press on",
outcome: `There would be time for history later. You marked the ruin on your chart`
+ ` with a careful hand and turned your face toward the horizon. The past could` + ` with a careful hand and turned your face toward the horizon. The past could`
+ ` wait; the future wouldn't.`, + ` wait; the future wouldn't.`,
}, },
@@ -171,23 +179,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "ask", description: "Asked what darker things lay deeper in the marsh, and listened carefully.",
label: "Ask what lies deeper in the marshes", id: "ask",
outcome: `He told you what the marsh-folk knew: that the darkness didn't end at the` label: "Ask what lies deeper in the marshes",
outcome: `He told you what the marsh-folk knew: that the darkness didn't end at the`
+ ` Kraken, that there were seams of shadow that ran all the way to the world's edge.` + ` Kraken, that there were seams of shadow that ran all the way to the world's edge.`
+ ` You thanked him and kept that information close.`, + ` You thanked him and kept that information close.`,
}, },
{ {
id: "lantern", description: "Accepted the lantern and moved on, carrying light into whatever came next.",
label: "Accept the lantern and move on", id: "lantern",
outcome: `You took the lantern. Light against darkness — it was a simple philosophy,` label: "Accept the lantern and move on",
outcome: `You took the lantern. Light against darkness — it was a simple philosophy,`
+ ` but it had served you well enough so far. The ferryman watched your guild` + ` but it had served you well enough so far. The ferryman watched your guild`
+ ` disappear into the mist and smiled, alone.`, + ` disappear into the mist and smiled, alone.`,
}, },
{ {
id: "rest", description: "Chose to rest with the marsh villages first, giving the guild time to heal.",
label: "Rest with the marsh villages first", id: "rest",
outcome: `Three days of sleeping on dry ground and eating hot food did more for your` label: "Rest with the marsh villages first",
outcome: `Three days of sleeping on dry ground and eating hot food did more for your`
+ ` guild than any potion. The marsh-folk gave generously and asked nothing. You left` + ` guild than any potion. The marsh-folk gave generously and asked nothing. You left`
+ ` them safer than you'd found them.`, + ` them safer than you'd found them.`,
}, },
@@ -213,23 +224,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "study", description: "Took the monk's journal and studied it carefully, preparing for what was coming.",
label: "Take the journal and study it carefully", id: "study",
outcome: `The journal became essential reading for your strongest strategists. The` label: "Take the journal and study it carefully",
outcome: `The journal became essential reading for your strongest strategists. The`
+ ` monk had been meticulous; his observations mapped a pattern that wasn't` + ` monk had been meticulous; his observations mapped a pattern that wasn't`
+ ` comforting. You began preparing for something larger than any single battle.`, + ` comforting. You began preparing for something larger than any single battle.`,
}, },
{ {
id: "promise", description: "Promised to return with answers, carrying the old monk's question as a compass.",
label: "Promise to return with answers", id: "promise",
outcome: `You couldn't take the old man down the mountain, but you could carry his` label: "Promise to return with answers",
outcome: `You couldn't take the old man down the mountain, but you could carry his`
+ ` question. The promise you made on that peak became something you returned to` + ` question. The promise you made on that peak became something you returned to`
+ ` often, in the quiet hours — a compass of its own.`, + ` often, in the quiet hours — a compass of its own.`,
}, },
{ {
id: "inquire", description: "Asked the monk what he believed was causing it, and descended with new understanding.",
label: "Ask the monk what he believes is causing it", id: "inquire",
outcome: `He didn't answer immediately. When he did, the words were careful: 'I think` label: "Ask the monk what he believes is causing it",
outcome: `He didn't answer immediately. When he did, the words were careful: 'I think`
+ ` something learned that it could come here. And now it knows the way.' You` + ` something learned that it could come here. And now it knows the way.' You`
+ ` descended the mountain knowing that the way in was also the way back.`, + ` descended the mountain knowing that the way in was also the way back.`,
}, },
@@ -255,22 +269,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "feather", description: "Kept the phoenix feather — not a trophy, but a question not yet answered.",
label: "Keep the feather as a reminder", id: "feather",
outcome: `You carried the feather in a sealed case from that day forward — not as a` label: "Keep the feather as a reminder",
outcome: `You carried the feather in a sealed case from that day forward — not as a`
+ ` trophy, but as a question you hadn't answered yet. What are you protecting? The` + ` trophy, but as a question you hadn't answered yet. What are you protecting? The`
+ ` question sharpened you.`, + ` question sharpened you.`,
}, },
{ {
id: "people", description: "Answered plainly: the guild protects its people. A truth held without wavering.",
label: "Tell her: you protect your people", id: "people",
outcome: `'Then don't lose them,' she said simply. It wasn't a warning. It was the` label: "Tell her: you protect your people",
outcome: `'Then don't lose them,' she said simply. It wasn't a warning. It was the`
+ ` closest thing to a blessing the volcanic depths had to offer.`, + ` closest thing to a blessing the volcanic depths had to offer.`,
}, },
{ {
id: "beyond", description: "Asked what lay beyond the fire, and carried the uncertainty forward like a live coal.",
label: "Ask what she thinks lies beyond the fire", id: "beyond",
outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that` label: "Ask what she thinks lies beyond the fire",
outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that`
+ ` has never needed to.' You weren't sure if that was reassuring. You carried the` + ` has never needed to.' You weren't sure if that was reassuring. You carried the`
+ ` uncertainty with you like a coal.`, + ` uncertainty with you like a coal.`,
}, },
@@ -297,24 +314,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "fight", description: "Said it plainly: small, and yet fighting anyway. A philosophy that spread far.",
label: "Yes — and we fight anyway", id: "fight",
outcome: `The philosopher wrote that down. She published it later, in an obscure` label: "Yes — and we fight anyway",
outcome: `The philosopher wrote that down. She published it later, in an obscure`
+ ` academic tract that circulated far wider than she'd expected. Small, and yet. And` + ` academic tract that circulated far wider than she'd expected. Small, and yet. And`
+ ` yet. And yet.`, + ` yet. And yet.`,
}, },
{ {
id: "further", description: "Asked what lay further out — and made sure that when noticed, it would be their mistake.",
label: "Ask what she thinks is further out", id: "further",
outcome: `She smiled, the way people smile when they've been waiting for the question.` label: "Ask what she thinks is further out",
outcome: `She smiled, the way people smile when they've been waiting for the question.`
+ ` 'Minds,' she said. 'Ancient, patient, watching. The question is whether they've` + ` 'Minds,' she said. 'Ancient, patient, watching. The question is whether they've`
+ ` noticed us yet.' You decided to make sure, when they did, that noticing you would` + ` noticed us yet.' You decided to make sure, when they did, that noticing you would`
+ ` be a mistake.`, + ` be a mistake.`,
}, },
{ {
id: "honest", description: "Admitted the silence of the Void still echoed inside, and let time fill it back in.",
label: "Admit the silence still echoes in you", id: "honest",
outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes` label: "Admit the silence still echoes in you",
outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes`
+ ` back.' She poured two cups of something hot and handed you one. 'The trick is to` + ` back.' She poured two cups of something hot and handed you one. 'The trick is to`
+ ` let the sound fill back in. Give it time.'`, + ` let the sound fill back in. Give it time.'`,
}, },
@@ -342,22 +362,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "memory", description: "Chose to carry the names of those who hadn't made it — weight and compass both.",
label: "Carry forward the memory of those lost", id: "memory",
outcome: `The names. The faces. The ones who hadn't made it as far as this height. You` label: "Carry forward the memory of those lost",
outcome: `The names. The faces. The ones who hadn't made it as far as this height. You`
+ ` held them as a weight and a compass both, and continued with your eyes open.`, + ` held them as a weight and a compass both, and continued with your eyes open.`,
}, },
{ {
id: "will", description: "Chose to carry the will to finish it: one step, then another, without stopping.",
label: "Carry forward the will to finish it", id: "will",
outcome: `The work was not done. The scale of it had grown, but the work remained:` label: "Carry forward the will to finish it",
outcome: `The work was not done. The scale of it had grown, but the work remained:`
+ ` take one more step, and then another, and do not stop until the last thing is` + ` take one more step, and then another, and do not stop until the last thing is`
+ ` settled. You were not built to leave things undone.`, + ` settled. You were not built to leave things undone.`,
}, },
{ {
id: "wonder", description: "Chose to carry wonder deliberately, refusing to become something cold and certain.",
label: "Carry forward wonder, against hardness", id: "wonder",
outcome: `It would have been easy, up here, to become something cold and certain. You` label: "Carry forward wonder, against hardness",
outcome: `It would have been easy, up here, to become something cold and certain. You`
+ ` chose differently. The capacity to be astonished — by starlight, by loyalty, by` + ` chose differently. The capacity to be astonished — by starlight, by loyalty, by`
+ ` the improbable fact of still being alive — you held on to that deliberately.`, + ` the improbable fact of still being alive — you held on to that deliberately.`,
}, },
@@ -384,24 +407,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "ask", description: "Asked what the naturalist thought was falling, and received an unsettling answer.",
label: "Ask what he thinks is falling", id: "ask",
outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate` label: "Ask what he thinks is falling",
outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate`
+ ` in one place. When too much of the world's weight tips in a single direction.' He` + ` in one place. When too much of the world's weight tips in a single direction.' He`
+ ` looked at you with an expression that was half-admiration, half-concern. You noted` + ` looked at you with an expression that was half-admiration, half-concern. You noted`
+ ` that he did not look away.`, + ` that he did not look away.`,
}, },
{ {
id: "accept", description: "Accepted that some things couldn't be predicted, holding the uncertainty like ballast.",
label: "Accept that some things can't be predicted", id: "accept",
outcome: `Not everything could be prepared for. This was a truth you had learned the` label: "Accept that some things can't be predicted",
outcome: `Not everything could be prepared for. This was a truth you had learned the`
+ ` hard way, and you'd learned it well enough to stop fighting it. You watched the` + ` hard way, and you'd learned it well enough to stop fighting it. You watched the`
+ ` surface settle and held the uncertainty like ballast.`, + ` surface settle and held the uncertainty like ballast.`,
}, },
{ {
id: "document", description: "Spent the return voyage writing — a record of pattern for whoever came after.",
label: "Document everything for whoever comes next", id: "document",
outcome: `If something woke what slept below, there would be others who needed to` label: "Document everything for whoever comes next",
outcome: `If something woke what slept below, there would be others who needed to`
+ ` know. You spent the return voyage writing — a record not of victory, but of` + ` know. You spent the return voyage writing — a record not of victory, but of`
+ ` pattern, for the eyes of whoever followed after.`, + ` pattern, for the eyes of whoever followed after.`,
}, },
@@ -427,24 +453,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "learn", description: "Asked the spirit what they had been warned about, and filed the answer carefully.",
label: "Ask what they were warned about", id: "learn",
outcome: `The spirit answered slowly, in the manner of things that have had too much` label: "Ask what they were warned about",
outcome: `The spirit answered slowly, in the manner of things that have had too much`
+ ` time to think. The warning had been about the Void — about the hunger at the edge` + ` time to think. The warning had been about the Void — about the hunger at the edge`
+ ` of everything. They had believed themselves beyond reach. You filed this away as` + ` of everything. They had believed themselves beyond reach. You filed this away as`
+ ` a lesson.`, + ` a lesson.`,
}, },
{ {
id: "silence", description: "Acknowledged the warning and left without a word, carrying a weight not unearned.",
label: "Acknowledge the warning and leave in silence", id: "silence",
outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in` label: "Acknowledge the warning and leave in silence",
outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in`
+ ` its way — acknowledged rather than dismissed. You left the court with a weight on` + ` its way — acknowledged rather than dismissed. You left the court with a weight on`
+ ` you that was not unearned.`, + ` you that was not unearned.`,
}, },
{ {
id: "vow", description: "Vowed the guild would not make the same mistake, and was watched all the way to the door.",
label: "Vow your guild won't make the same mistake", id: "vow",
outcome: `The spirit looked at you for a long time. 'That is what they said too,' it` label: "Vow your guild won't make the same mistake",
outcome: `The spirit looked at you for a long time. 'That is what they said too,' it`
+ ` finally replied. But it did not say it unkindly. And it watched you all the way` + ` finally replied. But it did not say it unkindly. And it watched you all the way`
+ ` to the door.`, + ` to the door.`,
}, },
@@ -471,23 +500,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "better", description: "Told the crystallographer the balance was not as bad as feared, and meant it.",
label: "Not as bad as I feared", id: "better",
outcome: `The crystallographer looked relieved in a way that surprised you — as though` label: "Not as bad as I feared",
outcome: `The crystallographer looked relieved in a way that surprised you — as though`
+ ` your answer was the one she'd needed to hear too. The balance of your guild was` + ` your answer was the one she'd needed to hear too. The balance of your guild was`
+ ` its people, more than its victories. You had not forgotten that. Not yet.`, + ` its people, more than its victories. You had not forgotten that. Not yet.`,
}, },
{ {
id: "expected", description: "Said the ledger showed exactly what was expected. Honest accounting, nothing more.",
label: "Exactly what I expected", id: "expected",
outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is` label: "Exactly what I expected",
outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is`
+ ` rarer than it should be.' Honesty about your own ledger was its own form of` + ` rarer than it should be.' Honesty about your own ledger was its own form of`
+ ` discipline.`, + ` discipline.`,
}, },
{ {
id: "quiet", description: "Said nothing of the balance. The ones who stay quiet are usually telling the truth.",
label: "I don't think I'm the one who should say", id: "quiet",
outcome: `She nodded slowly. 'The ones who say nothing are usually telling the truth,'` label: "I don't think I'm the one who should say",
outcome: `She nodded slowly. 'The ones who say nothing are usually telling the truth,'`
+ ` she said. There was no judgment in it. Only recognition.`, + ` she said. There was no judgment in it. Only recognition.`,
}, },
], ],
@@ -512,23 +544,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "sit", description: "Sat in the silence before leaving, letting the emptiness speak what it could.",
label: "Let the silence sit before leaving", id: "sit",
outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable` label: "Let the silence sit before leaving",
outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable`
+ ` place long enough to understand it. You sat. The silence told you what it could.` + ` place long enough to understand it. You sat. The silence told you what it could.`
+ ` When you left, you took that understanding with you.`, + ` When you left, you took that understanding with you.`,
}, },
{ {
id: "record", description: "Filled pages on the return, documenting the Void Emperor's nature for what lay ahead.",
label: "Record the Void Emperor's nature carefully", id: "record",
outcome: `If the Void had sent its best, it would send something different next time.` label: "Record the Void Emperor's nature carefully",
outcome: `If the Void had sent its best, it would send something different next time.`
+ ` Documentation was not heroism, but it was its own form of readiness. You filled` + ` Documentation was not heroism, but it was its own form of readiness. You filled`
+ ` pages on the return.`, + ` pages on the return.`,
}, },
{ {
id: "rally", description: "Rallied the guild before relief could settle. The Void had pulled back, not retreated.",
label: "Rally the guild — the work isn't done", id: "rally",
outcome: `There was no room for relief yet. The Void had pulled back, but pulling back` label: "Rally the guild — the work isn't done",
outcome: `There was no room for relief yet. The Void had pulled back, but pulling back`
+ ` was not retreating. You said this to your guild and they already knew it. That` + ` was not retreating. You said this to your guild and they already knew it. That`
+ ` was the measure of how far you had all come.`, + ` was the measure of how far you had all come.`,
}, },
@@ -553,23 +588,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "walk", description: "Turned their back on the throne and led the guild out. Not every power needs claiming.",
label: "Walk away from the throne", id: "walk",
outcome: `You turned your back on it and led your guild out. Not every power needs to` label: "Walk away from the throne",
outcome: `You turned your back on it and led your guild out. Not every power needs to`
+ ` be claimed. Not every throne needs an occupant. The room was quieter when you` + ` be claimed. Not every throne needs an occupant. The room was quieter when you`
+ ` left. You thought it might have been grateful.`, + ` left. You thought it might have been grateful.`,
}, },
{ {
id: "stand", description: "Stood at the throne's foot, acknowledged its weight, then turned toward the door.",
label: "Stand at its foot and make a decision", id: "stand",
outcome: `You did not sit. But you acknowledged it — the gravity of everything it` label: "Stand at its foot and make a decision",
outcome: `You did not sit. But you acknowledged it — the gravity of everything it`
+ ` represented, the cost and the weight and the long history. And then you looked` + ` represented, the cost and the weight and the long history. And then you looked`
+ ` away from it and toward the door, and that was its own kind of answer.`, + ` away from it and toward the door, and that was its own kind of answer.`,
}, },
{ {
id: "declare", description: "Declared aloud that power is held in trust — and the guild held that for a long time.",
label: "Declare that power is held in trust", id: "declare",
outcome: `The throne hummed louder, then quieter. You weren't sure if that was` label: "Declare that power is held in trust",
outcome: `The throne hummed louder, then quieter. You weren't sure if that was`
+ ` agreement or only vibration. But your guild heard you, and they held onto those` + ` agreement or only vibration. But your guild heard you, and they held onto those`
+ ` words for a long time afterward.`, + ` words for a long time afterward.`,
}, },
@@ -594,22 +632,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "before", description: "Asked what came before the before — accepted it had no shape yet, and moved on.",
label: "Ask what came before the before", id: "before",
outcome: `Silence. Then: That is not a question with a shape yet. You decided to` label: "Ask what came before the before",
outcome: `Silence. Then: That is not a question with a shape yet. You decided to`
+ ` accept that as an answer and move forward.`, + ` accept that as an answer and move forward.`,
}, },
{ {
id: "worth", description: "Affirmed that what was built is worth defending — the chaos agreed.",
label: "Affirm that what was built is worth defending", id: "worth",
outcome: `Yes, said the voice. That is why it has lasted. You were not sure what to` label: "Affirm that what was built is worth defending",
outcome: `Yes, said the voice. That is why it has lasted. You were not sure what to`
+ ` do with a compliment from the primordial chaos, but you received it with the` + ` do with a compliment from the primordial chaos, but you received it with the`
+ ` sincerity it was offered.`, + ` sincerity it was offered.`,
}, },
{ {
id: "fixed", description: "Stood in the chaos and felt their own solidity — specific, named, and decided.",
label: "Stand in the chaos and feel your own solidity", id: "fixed",
outcome: `Whatever you were — guild leader, fighter, something increasingly harder to` label: "Stand in the chaos and feel your own solidity",
outcome: `Whatever you were — guild leader, fighter, something increasingly harder to`
+ ` categorise — you were specific. Named. Decided. In the midst of all this` + ` categorise — you were specific. Named. Decided. In the midst of all this`
+ ` undecidedness, you were a fixed point, and that was enough.`, + ` undecidedness, you were a fixed point, and that was enough.`,
}, },
@@ -634,23 +675,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "stay", description: "Stayed with a weeping scout without a word, offering presence. It was what was needed.",
label: "Sit with your scout until the feeling passed", id: "stay",
outcome: `You stayed. There was no trick to it, no words that helped more than the` label: "Sit with your scout until the feeling passed",
outcome: `You stayed. There was no trick to it, no words that helped more than the`
+ ` simple fact of not being alone. The scout looked at you later with a complicated` + ` simple fact of not being alone. The scout looked at you later with a complicated`
+ ` expression that was mostly gratitude.`, + ` expression that was mostly gratitude.`,
}, },
{ {
id: "small", description: "Acknowledged the scale — and found the audacity in their smallness to persist.",
label: "Acknowledge the scale — and your smallness", id: "small",
outcome: `Big was not the same as better. The Expanse was infinite. Your guild was` label: "Acknowledge the scale — and your smallness",
outcome: `Big was not the same as better. The Expanse was infinite. Your guild was`
+ ` finite. And yet something in you had the audacity to persist in finite space and` + ` finite. And yet something in you had the audacity to persist in finite space and`
+ ` say: we are still here. You could live with that audacity.`, + ` say: we are still here. You could live with that audacity.`,
}, },
{ {
id: "plan", description: "Began planning immediately — and their scout looked on with fond exasperation.",
label: "Begin immediately planning the next move", id: "plan",
outcome: `Movement was your steadiest anchor. Your scout caught you making notes and` label: "Begin immediately planning the next move",
outcome: `Movement was your steadiest anchor. Your scout caught you making notes and`
+ ` shook their head, half exasperated and half relieved to see you so thoroughly` + ` shook their head, half exasperated and half relieved to see you so thoroughly`
+ ` yourself. You both knew it meant you were going to be all right.`, + ` yourself. You both knew it meant you were going to be all right.`,
}, },
@@ -676,23 +720,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "intact", description: "Left the Forge as found — wisdom in knowing what not to change.",
label: "Accept the invitation; leave the Forge intact", id: "intact",
outcome: `The Forge continued its quiet work. You left it as you found it, not because` label: "Accept the invitation; leave the Forge intact",
outcome: `The Forge continued its quiet work. You left it as you found it, not because`
+ ` you lacked the power to change it, but because some things had been put in place` + ` you lacked the power to change it, but because some things had been put in place`
+ ` by wiser hands than yours, and wisdom lay in knowing the difference.`, + ` by wiser hands than yours, and wisdom lay in knowing the difference.`,
}, },
{ {
id: "add", description: "Added a small notation to the blueprints, on the principle of memory.",
label: "Add a small note to the blueprints", id: "add",
outcome: `Your addition was modest — almost invisible. A small notation in the margin` label: "Add a small note to the blueprints",
outcome: `Your addition was modest — almost invisible. A small notation in the margin`
+ ` of the principle of memory: and what is remembered by those who choose to` + ` of the principle of memory: and what is remembered by those who choose to`
+ ` remember. Whether it had any effect, you never knew. You left it there anyway.`, + ` remember. Whether it had any effect, you never knew. You left it there anyway.`,
}, },
{ {
id: "write", description: "Documented what the Forge was — strange notes, accurate ones, for whoever needed them.",
label: "Write down what you observed, for others", id: "write",
outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The` label: "Write down what you observed, for others",
outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The`
+ ` notes would be strange, but they would be accurate, and accuracy was the only` + ` notes would be strange, but they would be accurate, and accuracy was the only`
+ ` thing the Forge itself seemed to care about.`, + ` thing the Forge itself seemed to care about.`,
}, },
@@ -718,23 +765,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "comfort", description: "Found it comforting. The stars persisted; so did what had been done in the time between.",
label: "Find it comforting — the universe persists", id: "comfort",
outcome: `The permanence of the stars was a kind of promise. What existed before you` label: "Find it comforting — the universe persists",
outcome: `The permanence of the stars was a kind of promise. What existed before you`
+ ` would exist after you, and what you did in the time between was not erased by` + ` would exist after you, and what you did in the time between was not erased by`
+ ` scale. You held onto this.`, + ` scale. You held onto this.`,
}, },
{ {
id: "grief", description: "Found it terrible — and turned back to their people, where the grief was real and theirs.",
label: "Find it terrible — your losses are not small", id: "grief",
outcome: `Your guild had bled for this. The grief of it was real and specific and` label: "Find it terrible — your losses are not small",
outcome: `Your guild had bled for this. The grief of it was real and specific and`
+ ` theirs, and the indifference of the cosmos did not diminish it. You turned away` + ` theirs, and the indifference of the cosmos did not diminish it. You turned away`
+ ` from the stars and toward your people.`, + ` from the stars and toward your people.`,
}, },
{ {
id: "present", description: "Found it neither — stood in the moment, let it be what it was, and called that enough.",
label: "Find it neither — just be present", id: "present",
outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what` label: "Find it neither — just be present",
outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what`
+ ` it was. The stars were what they were. That was enough, for now.`, + ` it was. The stars were what they were. That was enough, for now.`,
}, },
], ],
@@ -758,24 +808,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "weight", description: "Chose to carry the weight of all that came before — none of it unacknowledged.",
label: "Carry the weight of all that came before", id: "weight",
outcome: `The generations that had built the world — the forgotten, the unnamed, the` label: "Carry the weight of all that came before",
outcome: `The generations that had built the world — the forgotten, the unnamed, the`
+ ` ones whose courage made your existence possible — you acknowledged them. You were` + ` ones whose courage made your existence possible — you acknowledged them. You were`
+ ` not the beginning. You were what they had been working toward. That felt like` + ` not the beginning. You were what they had been working toward. That felt like`
+ ` enough.`, + ` enough.`,
}, },
{ {
id: "chosen", description: "Chose only what could be carried: the things that were truly theirs.",
label: "Carry only what you chose", id: "chosen",
outcome: `You could not carry everything. The weight would have stopped you where you` label: "Carry only what you chose",
outcome: `You could not carry everything. The weight would have stopped you where you`
+ ` stood. You chose carefully — the things that were yours, the things that mattered,` + ` stood. You chose carefully — the things that were yours, the things that mattered,`
+ ` the things that would survive the carrying.`, + ` the things that would survive the carrying.`,
}, },
{ {
id: "waste", description: "Chose the intention not to waste what they had reached, and made it real.",
label: "Carry the intention not to waste this", id: "waste",
outcome: `You had arrived somewhere very few had. What you did next would define what` label: "Carry the intention not to waste this",
outcome: `You had arrived somewhere very few had. What you did next would define what`
+ ` arriving here meant. You did not intend to waste it.`, + ` arriving here meant. You did not intend to waste it.`,
}, },
], ],
@@ -801,24 +854,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "yes", description: "Said yes without hesitation. Would have done it all again. The certainty was complete.",
label: "Yes — without hesitation", id: "yes",
outcome: `There was nothing complicated in it. The weight, the cost, the long road —` label: "Yes — without hesitation",
outcome: `There was nothing complicated in it. The weight, the cost, the long road —`
+ ` you would have done it again. Would do it again. The certainty was quiet and` + ` you would have done it again. Would do it again. The certainty was quiet and`
+ ` complete, and that was the most honest thing you had ever known.`, + ` complete, and that was the most honest thing you had ever known.`,
}, },
{ {
id: "cost", description: "Said yes, though the cost was real — holding both the loss and the worth without flinching.",
label: "Yes — though the cost was real", id: "cost",
outcome: `The acknowledgement of loss did not diminish the worth of it. Things had` label: "Yes — though the cost was real",
outcome: `The acknowledgement of loss did not diminish the worth of it. Things had`
+ ` been spent that could not be recovered. That was true. And the answer was still` + ` been spent that could not be recovered. That was true. And the answer was still`
+ ` yes. Holding both of those things at once was the truest thing you had ever` + ` yes. Holding both of those things at once was the truest thing you had ever`
+ ` managed.`, + ` managed.`,
}, },
{ {
id: "becoming", description: "Said the answer was still being written, and walked forward — as they always had.",
label: "I am still becoming the answer", id: "becoming",
outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You` label: "I am still becoming the answer",
outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You`
+ ` were still writing the rest of it. That was neither modesty nor avoidance — it` + ` were still writing the rest of it. That was neither modesty nor avoidance — it`
+ ` was honesty. You left the silence of the Absolute and walked forward, because` + ` was honesty. You left the silence of the Absolute and walked forward, because`
+ ` walking forward was what you did.`, + ` walking forward was what you did.`,
@@ -845,24 +901,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "know", description: "Told the guild: we know the way. The lessons passed forward to those who came next.",
label: "Tell the guild: we know the way", id: "know",
outcome: `The veterans who had made this choice with you nodded. The newer members` label: "Tell the guild: we know the way",
outcome: `The veterans who had made this choice with you nodded. The newer members`
+ ` looked uncertain. You had both in your guild, and that was the point — the` + ` looked uncertain. You had both in your guild, and that was the point — the`
+ ` knowledge passed forward, the lessons given to those who hadn't yet paid for` + ` knowledge passed forward, the lessons given to those who hadn't yet paid for`
+ ` them. That was the real economy of prestige.`, + ` them. That was the real economy of prestige.`,
}, },
{ {
id: "work", description: "Began again without ceremony — the work was what mattered.",
label: "Begin immediately, without ceremony", id: "work",
outcome: `There was a kind of respect in not making a production of it. The work was` label: "Begin immediately, without ceremony",
outcome: `There was a kind of respect in not making a production of it. The work was`
+ ` what mattered. The ceremony could wait for a summit that didn't keep moving. You` + ` what mattered. The ceremony could wait for a summit that didn't keep moving. You`
+ ` set to work, and your guild followed, and that was the whole of the ritual.`, + ` set to work, and your guild followed, and that was the whole of the ritual.`,
}, },
{ {
id: "rest", description: "Took one day. The guild rested, healed, and said things urgency hadn't left room for.",
label: "Take a single day to rest before restarting", id: "rest",
outcome: `One day. You had earned it, and so had they. The guild rested, and healed,` label: "Take a single day to rest before restarting",
outcome: `One day. You had earned it, and so had they. The guild rested, and healed,`
+ ` and ate without rushing, and said things to each other that the urgency of the` + ` and ate without rushing, and said things to each other that the urgency of the`
+ ` climb hadn't left room for. On the second morning you began again, and you began` + ` climb hadn't left room for. On the second morning you began again, and you began`
+ ` stronger.`, + ` stronger.`,
@@ -891,23 +950,26 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "speak", description: "Spoke honestly without preparation — the guild believed it, and that was the whole of it.",
label: "Speak to the guild about why you keep going", id: "speak",
outcome: `You hadn't planned to say anything, and what you said wasn't polished. But` label: "Speak to the guild about why you keep going",
outcome: `You hadn't planned to say anything, and what you said wasn't polished. But`
+ ` it was honest, and your guild heard it that way, and the room got quieter in the` + ` it was honest, and your guild heard it that way, and the room got quieter in the`
+ ` good way — the way of people deciding to believe in something together.`, + ` good way — the way of people deciding to believe in something together.`,
}, },
{ {
id: "listen", description: "Let the gathering speak for itself, and was grateful.",
label: "Let the gathering speak for itself", id: "listen",
outcome: `Sometimes leadership was knowing when not to speak. The guild had found its` label: "Let the gathering speak for itself",
outcome: `Sometimes leadership was knowing when not to speak. The guild had found its`
+ ` own reason to celebrate, its own meaning in the repetition. You listened and were` + ` own reason to celebrate, its own meaning in the repetition. You listened and were`
+ ` grateful.`, + ` grateful.`,
}, },
{ {
id: "store", description: "Committed the warmth and laughter to memory carefully, for the difficult nights ahead.",
label: "Commit the moment to memory, for hard times", id: "store",
outcome: `There would be difficult nights later. There always were. You stored this one` label: "Commit the moment to memory, for hard times",
outcome: `There would be difficult nights later. There always were. You stored this one`
+ ` carefully — the warmth of it, the sound of laughter, the proof that your people` + ` carefully — the warmth of it, the sound of laughter, the proof that your people`
+ ` were still whole — so that you could return to it when the cold came in.`, + ` were still whole — so that you could return to it when the cold came in.`,
}, },
@@ -935,22 +997,25 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "begin", description: "Accepted the strangeness and began. The discomfort was proof of somewhere genuinely new.",
label: "Accept the strangeness and begin", id: "begin",
outcome: `The unfamiliarity was not your enemy. It was proof that you were somewhere` label: "Accept the strangeness and begin",
outcome: `The unfamiliarity was not your enemy. It was proof that you were somewhere`
+ ` genuinely new. You held that discomfort lightly and took the first step.`, + ` genuinely new. You held that discomfort lightly and took the first step.`,
}, },
{ {
id: "grieve", description: "Sat with what was released before turning forward — loss and choice are not incompatible.",
label: "Sit with what was released before moving on", id: "grieve",
outcome: `Loss and choice were not incompatible. You had chosen to release, and what` label: "Sit with what was released before moving on",
outcome: `Loss and choice were not incompatible. You had chosen to release, and what`
+ ` you had released had been real and worth having. Acknowledging that before` + ` you had released had been real and worth having. Acknowledging that before`
+ ` turning forward was not weakness. It was honesty.`, + ` turning forward was not weakness. It was honesty.`,
}, },
{ {
id: "pattern", description: "Found the shape of the new pattern immediately. The guild felt steadier for it.",
label: "Find the shape of the new pattern immediately", id: "pattern",
outcome: `Your mind moved the way it always had, already mapping the new terrain. The` label: "Find the shape of the new pattern immediately",
outcome: `Your mind moved the way it always had, already mapping the new terrain. The`
+ ` guild watched you and felt steadier for it. Pattern-finding was its own form of` + ` guild watched you and felt steadier for it. Pattern-finding was its own form of`
+ ` courage — the refusal to be lost.`, + ` courage — the refusal to be lost.`,
}, },
@@ -977,24 +1042,27 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
{ {
choices: [ choices: [
{ {
id: "given", description: "Acknowledged what was given as much as what was earned. No path here was walked alone.",
label: "Acknowledge what was given as much as earned", id: "given",
outcome: `You had not walked this road alone. Every person who had followed you, every` label: "Acknowledge what was given as much as earned",
outcome: `You had not walked this road alone. Every person who had followed you, every`
+ ` ally who had helped, every predecessor whose failures had mapped the path — their` + ` ally who had helped, every predecessor whose failures had mapped the path — their`
+ ` contribution was woven into what you were now. You remembered them, and it` + ` contribution was woven into what you were now. You remembered them, and it`
+ ` mattered.`, + ` mattered.`,
}, },
{ {
id: "forward", description: "Looked forward to what this made possible, and felt excitement returning.",
label: "Look forward to what this makes possible", id: "forward",
outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.` label: "Look forward to what this makes possible",
outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.`
+ ` What you were now could do things that what you had been could only approach. You` + ` What you were now could do things that what you had been could only approach. You`
+ ` looked at the new horizon and felt something you had almost forgotten: excitement.`, + ` looked at the new horizon and felt something you had almost forgotten: excitement.`,
}, },
{ {
id: "be", description: "Let the weight of what they had become settle before the next step. Presence as power.",
label: "Simply be what you have become, for now", id: "be",
outcome: `Not every threshold needed to be rushed past. You were here. You were this.` label: "Simply be what you have become, for now",
outcome: `Not every threshold needed to be rushed past. You were here. You were this.`
+ ` You let the weight of that settle before you took the next step. Presence was its` + ` You let the weight of that settle before you took the next step. Presence was its`
+ ` own kind of power.`, + ` own kind of power.`,
}, },