generated from nhcarrigan/template
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b604a4aa5c
|
|||
|
e10eabc8b5
|
|||
|
c3d79e0c11
|
|||
|
6e2cb45553
|
|||
|
5a065998b6
|
|||
|
f9c925b9fc
|
|||
|
290c06de83
|
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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,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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 25–50%, red below 25%.
|
||||||
|
* @param percent - Current HP as a percentage (0–100).
|
||||||
|
* @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;
|
||||||
|
const intervalMs = 50;
|
||||||
|
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);
|
setBossHpPercent(bossEndPercent);
|
||||||
setPartyHpPercent(partyEndPercent);
|
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)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
<div className="achievement-toast-container">
|
||||||
<AchievementToast />
|
<AchievementToast />
|
||||||
<CodexToast />
|
<CodexToast />
|
||||||
|
<MilestoneToast />
|
||||||
|
<QuestCompleteToast />
|
||||||
|
<QuestFailedToast />
|
||||||
<StoryToast />
|
<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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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>
|
</div>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
const bossIds = addedIds.filter((id) => {
|
||||||
|
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) => {
|
setUnlockedCodexEntryIds((previous) => {
|
||||||
return [ ...previous, ...addedIds ];
|
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
@@ -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
@@ -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,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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,6 +10,7 @@ import type { Boss } from "./boss.js";
|
|||||||
import type { GameState } from "./gameState.js";
|
import type { GameState } from "./gameState.js";
|
||||||
|
|
||||||
interface StoryChoice {
|
interface StoryChoice {
|
||||||
|
description: string;
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
@@ -88,6 +90,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Accepted the map with quiet resolve, already looking east.",
|
||||||
id: "resolve",
|
id: "resolve",
|
||||||
label: "Accept the map with quiet resolve",
|
label: "Accept the map with quiet resolve",
|
||||||
outcome: `You folded the map carefully and tucked it away. Resolve was the only`
|
outcome: `You folded the map carefully and tucked it away. Resolve was the only`
|
||||||
@@ -95,6 +98,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` this one has the look of someone who finishes things.`,
|
+ ` this one has the look of someone who finishes things.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Turned back to their people first — some leaders are built for their guild.",
|
||||||
id: "people",
|
id: "people",
|
||||||
label: "Return immediately to your people",
|
label: "Return immediately to your people",
|
||||||
outcome: `Your first thought was of your guild — of wounds to tend and rest`
|
outcome: `Your first thought was of your guild — of wounds to tend and rest`
|
||||||
@@ -102,6 +106,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` glory; some are built for their people. You were becoming the latter.`,
|
+ ` glory; some are built for their people. You were becoming the latter.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Studied the map in silence, already charting the next move.",
|
||||||
id: "plan",
|
id: "plan",
|
||||||
label: "Study it in silence, already planning",
|
label: "Study it in silence, already planning",
|
||||||
outcome: `Your eyes moved across the map before she'd even finished speaking. The`
|
outcome: `Your eyes moved across the map before she'd even finished speaking. The`
|
||||||
@@ -129,6 +134,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Stayed to hear the scholar's findings, filing every warning about what had ended the city.",
|
||||||
id: "listen",
|
id: "listen",
|
||||||
label: "Ask the scholar what she has learned",
|
label: "Ask the scholar what she has learned",
|
||||||
outcome: `You stayed long enough to listen. The scholar was cautious with her theories`
|
outcome: `You stayed long enough to listen. The scholar was cautious with her theories`
|
||||||
@@ -137,6 +143,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` knowledge away like a sharp blade.`,
|
+ ` knowledge away like a sharp blade.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Claimed the ancient hall as a waystation — filling old bones with new purpose.",
|
||||||
id: "claim",
|
id: "claim",
|
||||||
label: "Claim the hall as a guild waystation",
|
label: "Claim the hall as a guild waystation",
|
||||||
outcome: `The ruins needed purpose more than they needed silence. Your guild cleared`
|
outcome: `The ruins needed purpose more than they needed silence. Your guild cleared`
|
||||||
@@ -144,6 +151,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` age. Whatever had ended the people here, it would not end you.`,
|
+ ` age. Whatever had ended the people here, it would not end you.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Marked the ruin on the chart and pressed on. History could wait.",
|
||||||
id: "press",
|
id: "press",
|
||||||
label: "Mark it on your chart and press on",
|
label: "Mark it on your chart and press on",
|
||||||
outcome: `There would be time for history later. You marked the ruin on your chart`
|
outcome: `There would be time for history later. You marked the ruin on your chart`
|
||||||
@@ -171,6 +179,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Asked what darker things lay deeper in the marsh, and listened carefully.",
|
||||||
id: "ask",
|
id: "ask",
|
||||||
label: "Ask what lies deeper in the marshes",
|
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`
|
outcome: `He told you what the marsh-folk knew: that the darkness didn't end at the`
|
||||||
@@ -178,6 +187,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` You thanked him and kept that information close.`,
|
+ ` You thanked him and kept that information close.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Accepted the lantern and moved on, carrying light into whatever came next.",
|
||||||
id: "lantern",
|
id: "lantern",
|
||||||
label: "Accept the lantern and move on",
|
label: "Accept the lantern and move on",
|
||||||
outcome: `You took the lantern. Light against darkness — it was a simple philosophy,`
|
outcome: `You took the lantern. Light against darkness — it was a simple philosophy,`
|
||||||
@@ -185,6 +195,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` disappear into the mist and smiled, alone.`,
|
+ ` disappear into the mist and smiled, alone.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Chose to rest with the marsh villages first, giving the guild time to heal.",
|
||||||
id: "rest",
|
id: "rest",
|
||||||
label: "Rest with the marsh villages first",
|
label: "Rest with the marsh villages first",
|
||||||
outcome: `Three days of sleeping on dry ground and eating hot food did more for your`
|
outcome: `Three days of sleeping on dry ground and eating hot food did more for your`
|
||||||
@@ -213,6 +224,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Took the monk's journal and studied it carefully, preparing for what was coming.",
|
||||||
id: "study",
|
id: "study",
|
||||||
label: "Take the journal and study it carefully",
|
label: "Take the journal and study it carefully",
|
||||||
outcome: `The journal became essential reading for your strongest strategists. The`
|
outcome: `The journal became essential reading for your strongest strategists. The`
|
||||||
@@ -220,6 +232,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` comforting. You began preparing for something larger than any single battle.`,
|
+ ` comforting. You began preparing for something larger than any single battle.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Promised to return with answers, carrying the old monk's question as a compass.",
|
||||||
id: "promise",
|
id: "promise",
|
||||||
label: "Promise to return with answers",
|
label: "Promise to return with answers",
|
||||||
outcome: `You couldn't take the old man down the mountain, but you could carry his`
|
outcome: `You couldn't take the old man down the mountain, but you could carry his`
|
||||||
@@ -227,6 +240,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` often, in the quiet hours — a compass of its own.`,
|
+ ` often, in the quiet hours — a compass of its own.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Asked the monk what he believed was causing it, and descended with new understanding.",
|
||||||
id: "inquire",
|
id: "inquire",
|
||||||
label: "Ask the monk what he believes is causing it",
|
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`
|
outcome: `He didn't answer immediately. When he did, the words were careful: 'I think`
|
||||||
@@ -255,6 +269,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Kept the phoenix feather — not a trophy, but a question not yet answered.",
|
||||||
id: "feather",
|
id: "feather",
|
||||||
label: "Keep the feather as a reminder",
|
label: "Keep the feather as a reminder",
|
||||||
outcome: `You carried the feather in a sealed case from that day forward — not as a`
|
outcome: `You carried the feather in a sealed case from that day forward — not as a`
|
||||||
@@ -262,12 +277,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` question sharpened you.`,
|
+ ` question sharpened you.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Answered plainly: the guild protects its people. A truth held without wavering.",
|
||||||
id: "people",
|
id: "people",
|
||||||
label: "Tell her: you protect your people",
|
label: "Tell her: you protect your people",
|
||||||
outcome: `'Then don't lose them,' she said simply. It wasn't a warning. It was the`
|
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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Asked what lay beyond the fire, and carried the uncertainty forward like a live coal.",
|
||||||
id: "beyond",
|
id: "beyond",
|
||||||
label: "Ask what she thinks lies beyond the fire",
|
label: "Ask what she thinks lies beyond the fire",
|
||||||
outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that`
|
outcome: `'Something that cannot burn,' she said, after a long pause. 'Something that`
|
||||||
@@ -297,6 +314,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Said it plainly: small, and yet fighting anyway. A philosophy that spread far.",
|
||||||
id: "fight",
|
id: "fight",
|
||||||
label: "Yes — and we fight anyway",
|
label: "Yes — and we fight anyway",
|
||||||
outcome: `The philosopher wrote that down. She published it later, in an obscure`
|
outcome: `The philosopher wrote that down. She published it later, in an obscure`
|
||||||
@@ -304,6 +322,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` yet. And yet.`,
|
+ ` yet. And yet.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Asked what lay further out — and made sure that when noticed, it would be their mistake.",
|
||||||
id: "further",
|
id: "further",
|
||||||
label: "Ask what she thinks is further out",
|
label: "Ask what she thinks is further out",
|
||||||
outcome: `She smiled, the way people smile when they've been waiting for the question.`
|
outcome: `She smiled, the way people smile when they've been waiting for the question.`
|
||||||
@@ -312,6 +331,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` be a mistake.`,
|
+ ` be a mistake.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Admitted the silence of the Void still echoed inside, and let time fill it back in.",
|
||||||
id: "honest",
|
id: "honest",
|
||||||
label: "Admit the silence still echoes in you",
|
label: "Admit the silence still echoes in you",
|
||||||
outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes`
|
outcome: `She nodded, unsurprised. 'It does that. To everyone who goes there and comes`
|
||||||
@@ -342,12 +362,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Chose to carry the names of those who hadn't made it — weight and compass both.",
|
||||||
id: "memory",
|
id: "memory",
|
||||||
label: "Carry forward the memory of those lost",
|
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`
|
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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Chose to carry the will to finish it: one step, then another, without stopping.",
|
||||||
id: "will",
|
id: "will",
|
||||||
label: "Carry forward the will to finish it",
|
label: "Carry forward the will to finish it",
|
||||||
outcome: `The work was not done. The scale of it had grown, but the work remained:`
|
outcome: `The work was not done. The scale of it had grown, but the work remained:`
|
||||||
@@ -355,6 +377,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` settled. You were not built to leave things undone.`,
|
+ ` settled. You were not built to leave things undone.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Chose to carry wonder deliberately, refusing to become something cold and certain.",
|
||||||
id: "wonder",
|
id: "wonder",
|
||||||
label: "Carry forward wonder, against hardness",
|
label: "Carry forward wonder, against hardness",
|
||||||
outcome: `It would have been easy, up here, to become something cold and certain. You`
|
outcome: `It would have been easy, up here, to become something cold and certain. You`
|
||||||
@@ -384,6 +407,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Asked what the naturalist thought was falling, and received an unsettling answer.",
|
||||||
id: "ask",
|
id: "ask",
|
||||||
label: "Ask what he thinks is falling",
|
label: "Ask what he thinks is falling",
|
||||||
outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate`
|
outcome: `'Pressure,' he said. 'The kind that builds when too many powers concentrate`
|
||||||
@@ -392,6 +416,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` that he did not look away.`,
|
+ ` that he did not look away.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Accepted that some things couldn't be predicted, holding the uncertainty like ballast.",
|
||||||
id: "accept",
|
id: "accept",
|
||||||
label: "Accept that some things can't be predicted",
|
label: "Accept that some things can't be predicted",
|
||||||
outcome: `Not everything could be prepared for. This was a truth you had learned the`
|
outcome: `Not everything could be prepared for. This was a truth you had learned the`
|
||||||
@@ -399,6 +424,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` surface settle and held the uncertainty like ballast.`,
|
+ ` surface settle and held the uncertainty like ballast.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Spent the return voyage writing — a record of pattern for whoever came after.",
|
||||||
id: "document",
|
id: "document",
|
||||||
label: "Document everything for whoever comes next",
|
label: "Document everything for whoever comes next",
|
||||||
outcome: `If something woke what slept below, there would be others who needed to`
|
outcome: `If something woke what slept below, there would be others who needed to`
|
||||||
@@ -427,6 +453,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Asked the spirit what they had been warned about, and filed the answer carefully.",
|
||||||
id: "learn",
|
id: "learn",
|
||||||
label: "Ask what they were warned about",
|
label: "Ask what they were warned about",
|
||||||
outcome: `The spirit answered slowly, in the manner of things that have had too much`
|
outcome: `The spirit answered slowly, in the manner of things that have had too much`
|
||||||
@@ -435,6 +462,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` a lesson.`,
|
+ ` a lesson.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Acknowledged the warning and left without a word, carrying a weight not unearned.",
|
||||||
id: "silence",
|
id: "silence",
|
||||||
label: "Acknowledge the warning and leave in silence",
|
label: "Acknowledge the warning and leave in silence",
|
||||||
outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in`
|
outcome: `Some moments asked for silence. You gave it. The spirit seemed grateful, in`
|
||||||
@@ -442,6 +470,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` you that was not unearned.`,
|
+ ` you that was not unearned.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Vowed the guild would not make the same mistake, and was watched all the way to the door.",
|
||||||
id: "vow",
|
id: "vow",
|
||||||
label: "Vow your guild won't make the same mistake",
|
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`
|
outcome: `The spirit looked at you for a long time. 'That is what they said too,' it`
|
||||||
@@ -471,6 +500,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Told the crystallographer the balance was not as bad as feared, and meant it.",
|
||||||
id: "better",
|
id: "better",
|
||||||
label: "Not as bad as I feared",
|
label: "Not as bad as I feared",
|
||||||
outcome: `The crystallographer looked relieved in a way that surprised you — as though`
|
outcome: `The crystallographer looked relieved in a way that surprised you — as though`
|
||||||
@@ -478,6 +508,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` 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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Said the ledger showed exactly what was expected. Honest accounting, nothing more.",
|
||||||
id: "expected",
|
id: "expected",
|
||||||
label: "Exactly what I expected",
|
label: "Exactly what I expected",
|
||||||
outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is`
|
outcome: `'Then you have been paying attention,' she said, quietly approving. 'That is`
|
||||||
@@ -485,6 +516,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` discipline.`,
|
+ ` discipline.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Said nothing of the balance. The ones who stay quiet are usually telling the truth.",
|
||||||
id: "quiet",
|
id: "quiet",
|
||||||
label: "I don't think I'm the one who should say",
|
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,'`
|
outcome: `She nodded slowly. 'The ones who say nothing are usually telling the truth,'`
|
||||||
@@ -512,6 +544,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Sat in the silence before leaving, letting the emptiness speak what it could.",
|
||||||
id: "sit",
|
id: "sit",
|
||||||
label: "Let the silence sit before leaving",
|
label: "Let the silence sit before leaving",
|
||||||
outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable`
|
outcome: `Wisdom, sometimes, is the willingness to remain still in an uncomfortable`
|
||||||
@@ -519,6 +552,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` When you left, you took that understanding with you.`,
|
+ ` When you left, you took that understanding with you.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Filled pages on the return, documenting the Void Emperor's nature for what lay ahead.",
|
||||||
id: "record",
|
id: "record",
|
||||||
label: "Record the Void Emperor's nature carefully",
|
label: "Record the Void Emperor's nature carefully",
|
||||||
outcome: `If the Void had sent its best, it would send something different next time.`
|
outcome: `If the Void had sent its best, it would send something different next time.`
|
||||||
@@ -526,6 +560,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` pages on the return.`,
|
+ ` pages on the return.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Rallied the guild before relief could settle. The Void had pulled back, not retreated.",
|
||||||
id: "rally",
|
id: "rally",
|
||||||
label: "Rally the guild — the work isn't done",
|
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`
|
outcome: `There was no room for relief yet. The Void had pulled back, but pulling back`
|
||||||
@@ -553,6 +588,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Turned their back on the throne and led the guild out. Not every power needs claiming.",
|
||||||
id: "walk",
|
id: "walk",
|
||||||
label: "Walk away from the throne",
|
label: "Walk away from the throne",
|
||||||
outcome: `You turned your back on it and led your guild out. Not every power needs to`
|
outcome: `You turned your back on it and led your guild out. Not every power needs to`
|
||||||
@@ -560,6 +596,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` left. You thought it might have been grateful.`,
|
+ ` left. You thought it might have been grateful.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Stood at the throne's foot, acknowledged its weight, then turned toward the door.",
|
||||||
id: "stand",
|
id: "stand",
|
||||||
label: "Stand at its foot and make a decision",
|
label: "Stand at its foot and make a decision",
|
||||||
outcome: `You did not sit. But you acknowledged it — the gravity of everything it`
|
outcome: `You did not sit. But you acknowledged it — the gravity of everything it`
|
||||||
@@ -567,6 +604,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` 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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Declared aloud that power is held in trust — and the guild held that for a long time.",
|
||||||
id: "declare",
|
id: "declare",
|
||||||
label: "Declare that power is held in trust",
|
label: "Declare that power is held in trust",
|
||||||
outcome: `The throne hummed louder, then quieter. You weren't sure if that was`
|
outcome: `The throne hummed louder, then quieter. You weren't sure if that was`
|
||||||
@@ -594,12 +632,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Asked what came before the before — accepted it had no shape yet, and moved on.",
|
||||||
id: "before",
|
id: "before",
|
||||||
label: "Ask what came before the before",
|
label: "Ask what came before the before",
|
||||||
outcome: `Silence. Then: That is not a question with a shape yet. You decided to`
|
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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Affirmed that what was built is worth defending — the chaos agreed.",
|
||||||
id: "worth",
|
id: "worth",
|
||||||
label: "Affirm that what was built is worth defending",
|
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`
|
outcome: `Yes, said the voice. That is why it has lasted. You were not sure what to`
|
||||||
@@ -607,6 +647,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` sincerity it was offered.`,
|
+ ` sincerity it was offered.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Stood in the chaos and felt their own solidity — specific, named, and decided.",
|
||||||
id: "fixed",
|
id: "fixed",
|
||||||
label: "Stand in the chaos and feel your own solidity",
|
label: "Stand in the chaos and feel your own solidity",
|
||||||
outcome: `Whatever you were — guild leader, fighter, something increasingly harder to`
|
outcome: `Whatever you were — guild leader, fighter, something increasingly harder to`
|
||||||
@@ -634,6 +675,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Stayed with a weeping scout without a word, offering presence. It was what was needed.",
|
||||||
id: "stay",
|
id: "stay",
|
||||||
label: "Sit with your scout until the feeling passed",
|
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`
|
outcome: `You stayed. There was no trick to it, no words that helped more than the`
|
||||||
@@ -641,6 +683,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` expression that was mostly gratitude.`,
|
+ ` expression that was mostly gratitude.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Acknowledged the scale — and found the audacity in their smallness to persist.",
|
||||||
id: "small",
|
id: "small",
|
||||||
label: "Acknowledge the scale — and your smallness",
|
label: "Acknowledge the scale — and your smallness",
|
||||||
outcome: `Big was not the same as better. The Expanse was infinite. Your guild was`
|
outcome: `Big was not the same as better. The Expanse was infinite. Your guild was`
|
||||||
@@ -648,6 +691,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` say: we are still here. You could live with that audacity.`,
|
+ ` say: we are still here. You could live with that audacity.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Began planning immediately — and their scout looked on with fond exasperation.",
|
||||||
id: "plan",
|
id: "plan",
|
||||||
label: "Begin immediately planning the next move",
|
label: "Begin immediately planning the next move",
|
||||||
outcome: `Movement was your steadiest anchor. Your scout caught you making notes and`
|
outcome: `Movement was your steadiest anchor. Your scout caught you making notes and`
|
||||||
@@ -676,6 +720,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Left the Forge as found — wisdom in knowing what not to change.",
|
||||||
id: "intact",
|
id: "intact",
|
||||||
label: "Accept the invitation; leave the Forge intact",
|
label: "Accept the invitation; leave the Forge intact",
|
||||||
outcome: `The Forge continued its quiet work. You left it as you found it, not because`
|
outcome: `The Forge continued its quiet work. You left it as you found it, not because`
|
||||||
@@ -683,6 +728,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` by wiser hands than yours, and wisdom lay in knowing the difference.`,
|
+ ` by wiser hands than yours, and wisdom lay in knowing the difference.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Added a small notation to the blueprints, on the principle of memory.",
|
||||||
id: "add",
|
id: "add",
|
||||||
label: "Add a small note to the blueprints",
|
label: "Add a small note to the blueprints",
|
||||||
outcome: `Your addition was modest — almost invisible. A small notation in the margin`
|
outcome: `Your addition was modest — almost invisible. A small notation in the margin`
|
||||||
@@ -690,6 +736,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` 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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Documented what the Forge was — strange notes, accurate ones, for whoever needed them.",
|
||||||
id: "write",
|
id: "write",
|
||||||
label: "Write down what you observed, for others",
|
label: "Write down what you observed, for others",
|
||||||
outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The`
|
outcome: `Documentation felt inadequate for what the Forge was. You did it anyway. The`
|
||||||
@@ -718,6 +765,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Found it comforting. The stars persisted; so did what had been done in the time between.",
|
||||||
id: "comfort",
|
id: "comfort",
|
||||||
label: "Find it comforting — the universe persists",
|
label: "Find it comforting — the universe persists",
|
||||||
outcome: `The permanence of the stars was a kind of promise. What existed before you`
|
outcome: `The permanence of the stars was a kind of promise. What existed before you`
|
||||||
@@ -725,6 +773,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` scale. You held onto this.`,
|
+ ` scale. You held onto this.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Found it terrible — and turned back to their people, where the grief was real and theirs.",
|
||||||
id: "grief",
|
id: "grief",
|
||||||
label: "Find it terrible — your losses are not small",
|
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`
|
outcome: `Your guild had bled for this. The grief of it was real and specific and`
|
||||||
@@ -732,6 +781,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` from the stars and toward your people.`,
|
+ ` from the stars and toward your people.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Found it neither — stood in the moment, let it be what it was, and called that enough.",
|
||||||
id: "present",
|
id: "present",
|
||||||
label: "Find it neither — just be present",
|
label: "Find it neither — just be present",
|
||||||
outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what`
|
outcome: `Sometimes a moment did not need interpretation. You stood in it. It was what`
|
||||||
@@ -758,6 +808,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Chose to carry the weight of all that came before — none of it unacknowledged.",
|
||||||
id: "weight",
|
id: "weight",
|
||||||
label: "Carry the weight of all that came before",
|
label: "Carry the weight of all that came before",
|
||||||
outcome: `The generations that had built the world — the forgotten, the unnamed, the`
|
outcome: `The generations that had built the world — the forgotten, the unnamed, the`
|
||||||
@@ -766,6 +817,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` enough.`,
|
+ ` enough.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Chose only what could be carried: the things that were truly theirs.",
|
||||||
id: "chosen",
|
id: "chosen",
|
||||||
label: "Carry only what you chose",
|
label: "Carry only what you chose",
|
||||||
outcome: `You could not carry everything. The weight would have stopped you where you`
|
outcome: `You could not carry everything. The weight would have stopped you where you`
|
||||||
@@ -773,6 +825,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` the things that would survive the carrying.`,
|
+ ` the things that would survive the carrying.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Chose the intention not to waste what they had reached, and made it real.",
|
||||||
id: "waste",
|
id: "waste",
|
||||||
label: "Carry the intention not to waste this",
|
label: "Carry the intention not to waste this",
|
||||||
outcome: `You had arrived somewhere very few had. What you did next would define what`
|
outcome: `You had arrived somewhere very few had. What you did next would define what`
|
||||||
@@ -801,6 +854,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Said yes without hesitation. Would have done it all again. The certainty was complete.",
|
||||||
id: "yes",
|
id: "yes",
|
||||||
label: "Yes — without hesitation",
|
label: "Yes — without hesitation",
|
||||||
outcome: `There was nothing complicated in it. The weight, the cost, the long road —`
|
outcome: `There was nothing complicated in it. The weight, the cost, the long road —`
|
||||||
@@ -808,6 +862,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` complete, and that was the most honest thing you had ever known.`,
|
+ ` complete, and that was the most honest thing you had ever known.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Said yes, though the cost was real — holding both the loss and the worth without flinching.",
|
||||||
id: "cost",
|
id: "cost",
|
||||||
label: "Yes — though the cost was real",
|
label: "Yes — though the cost was real",
|
||||||
outcome: `The acknowledgement of loss did not diminish the worth of it. Things had`
|
outcome: `The acknowledgement of loss did not diminish the worth of it. Things had`
|
||||||
@@ -816,6 +871,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` managed.`,
|
+ ` managed.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Said the answer was still being written, and walked forward — as they always had.",
|
||||||
id: "becoming",
|
id: "becoming",
|
||||||
label: "I am still becoming the answer",
|
label: "I am still becoming the answer",
|
||||||
outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You`
|
outcome: `The journey had not ended. The Absolute was a chapter, not a conclusion. You`
|
||||||
@@ -845,6 +901,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Told the guild: we know the way. The lessons passed forward to those who came next.",
|
||||||
id: "know",
|
id: "know",
|
||||||
label: "Tell the guild: we know the way",
|
label: "Tell the guild: we know the way",
|
||||||
outcome: `The veterans who had made this choice with you nodded. The newer members`
|
outcome: `The veterans who had made this choice with you nodded. The newer members`
|
||||||
@@ -853,6 +910,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` them. That was the real economy of prestige.`,
|
+ ` them. That was the real economy of prestige.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Began again without ceremony — the work was what mattered.",
|
||||||
id: "work",
|
id: "work",
|
||||||
label: "Begin immediately, without ceremony",
|
label: "Begin immediately, without ceremony",
|
||||||
outcome: `There was a kind of respect in not making a production of it. The work was`
|
outcome: `There was a kind of respect in not making a production of it. The work was`
|
||||||
@@ -860,6 +918,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` 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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Took one day. The guild rested, healed, and said things urgency hadn't left room for.",
|
||||||
id: "rest",
|
id: "rest",
|
||||||
label: "Take a single day to rest before restarting",
|
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,`
|
outcome: `One day. You had earned it, and so had they. The guild rested, and healed,`
|
||||||
@@ -891,6 +950,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Spoke honestly without preparation — the guild believed it, and that was the whole of it.",
|
||||||
id: "speak",
|
id: "speak",
|
||||||
label: "Speak to the guild about why you keep going",
|
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`
|
outcome: `You hadn't planned to say anything, and what you said wasn't polished. But`
|
||||||
@@ -898,6 +958,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` good way — the way of people deciding to believe in something together.`,
|
+ ` good way — the way of people deciding to believe in something together.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Let the gathering speak for itself, and was grateful.",
|
||||||
id: "listen",
|
id: "listen",
|
||||||
label: "Let the gathering speak for itself",
|
label: "Let the gathering speak for itself",
|
||||||
outcome: `Sometimes leadership was knowing when not to speak. The guild had found its`
|
outcome: `Sometimes leadership was knowing when not to speak. The guild had found its`
|
||||||
@@ -905,6 +966,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` grateful.`,
|
+ ` grateful.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Committed the warmth and laughter to memory carefully, for the difficult nights ahead.",
|
||||||
id: "store",
|
id: "store",
|
||||||
label: "Commit the moment to memory, for hard times",
|
label: "Commit the moment to memory, for hard times",
|
||||||
outcome: `There would be difficult nights later. There always were. You stored this one`
|
outcome: `There would be difficult nights later. There always were. You stored this one`
|
||||||
@@ -935,12 +997,14 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Accepted the strangeness and began. The discomfort was proof of somewhere genuinely new.",
|
||||||
id: "begin",
|
id: "begin",
|
||||||
label: "Accept the strangeness and begin",
|
label: "Accept the strangeness and begin",
|
||||||
outcome: `The unfamiliarity was not your enemy. It was proof that you were somewhere`
|
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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Sat with what was released before turning forward — loss and choice are not incompatible.",
|
||||||
id: "grieve",
|
id: "grieve",
|
||||||
label: "Sit with what was released before moving on",
|
label: "Sit with what was released before moving on",
|
||||||
outcome: `Loss and choice were not incompatible. You had chosen to release, and what`
|
outcome: `Loss and choice were not incompatible. You had chosen to release, and what`
|
||||||
@@ -948,6 +1012,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` turning forward was not weakness. It was honesty.`,
|
+ ` turning forward was not weakness. It was honesty.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Found the shape of the new pattern immediately. The guild felt steadier for it.",
|
||||||
id: "pattern",
|
id: "pattern",
|
||||||
label: "Find the shape of the new pattern immediately",
|
label: "Find the shape of the new pattern immediately",
|
||||||
outcome: `Your mind moved the way it always had, already mapping the new terrain. The`
|
outcome: `Your mind moved the way it always had, already mapping the new terrain. The`
|
||||||
@@ -977,6 +1042,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
{
|
{
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
|
description: "Acknowledged what was given as much as what was earned. No path here was walked alone.",
|
||||||
id: "given",
|
id: "given",
|
||||||
label: "Acknowledge what was given as much as earned",
|
label: "Acknowledge what was given as much as earned",
|
||||||
outcome: `You had not walked this road alone. Every person who had followed you, every`
|
outcome: `You had not walked this road alone. Every person who had followed you, every`
|
||||||
@@ -985,6 +1051,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` mattered.`,
|
+ ` mattered.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Looked forward to what this made possible, and felt excitement returning.",
|
||||||
id: "forward",
|
id: "forward",
|
||||||
label: "Look forward to what this makes possible",
|
label: "Look forward to what this makes possible",
|
||||||
outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.`
|
outcome: `The horizon had not disappeared. It had moved — further, broader, stranger.`
|
||||||
@@ -992,6 +1059,7 @@ const STORY_CHAPTERS: Array<StoryChapter> = [
|
|||||||
+ ` 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.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
description: "Let the weight of what they had become settle before the next step. Presence as power.",
|
||||||
id: "be",
|
id: "be",
|
||||||
label: "Simply be what you have become, for now",
|
label: "Simply be what you have become, for now",
|
||||||
outcome: `Not every threshold needed to be rushed past. You were here. You were this.`
|
outcome: `Not every threshold needed to be rushed past. You were here. You were this.`
|
||||||
|
|||||||
Reference in New Issue
Block a user