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

- 22 chapters across the full game arc (18 zone bosses + 4 milestones)
- Choice-based narrative with {characterName} dynamic substitution
- Story progress (unlocked + completed chapters) is permanent across all resets
- Server-side anti-cheat: chapters/choices can only accumulate, never be removed
- Tab badge and lower-right toast notifications for newly unlocked chapters
- Story choices displayed on the Character Sheet and Character Page
- How to Play entry added to About panel
This commit is contained in:
2026-03-07 17:15:08 -08:00
committed by Naomi Carrigan
parent ad5f2ad226
commit 4ed3ccc69c
14 changed files with 1146 additions and 5 deletions
+22 -1
View File
@@ -373,7 +373,28 @@ const validateAndSanitize = (incoming: GameState, previous: GameState): GameStat
};
})();
return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread, ...apotheosisSpread, ...explorationSpread };
// Story progress: completed chapters can only grow, unlocked IDs can only grow.
// Low cheat risk (no rewards), so we allow all incoming additions.
const storySpread = (() => {
if (!incoming.story) return previous.story ? { story: previous.story } : {};
const prevUnlocked = previous.story?.unlockedChapterIds ?? [];
const prevCompleted = previous.story?.completedChapters ?? [];
// Allow new chapter IDs to be added; never remove existing ones
const unlockedChapterIds = [
...prevUnlocked,
...incoming.story.unlockedChapterIds.filter((id) => !prevUnlocked.includes(id)),
];
// Allow new completed chapters; never remove existing ones (one entry per chapter)
const completedChapters = [
...prevCompleted,
...incoming.story.completedChapters.filter(
(c) => !prevCompleted.some((p) => p.chapterId === c.chapterId),
),
];
return { story: { unlockedChapterIds, completedChapters } };
})();
return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread, ...apotheosisSpread, ...explorationSpread, ...storySpread };
};
export const gameRouter = new Hono<HonoEnv>();
+2
View File
@@ -35,6 +35,8 @@ export const buildPostApotheosisState = (
...(currentState.codex ? { codex: currentState.codex } : {}),
// Apotheosis data is eternal — never wiped by any reset
apotheosis: newApotheosisData,
// Story chapter progress is permanent — survives all resets
...(currentState.story ? { story: currentState.story } : {}),
};
return { newState, newApotheosisData };
+2
View File
@@ -122,6 +122,8 @@ export const buildPostPrestigeState = (
...(currentState.transcendence ? { transcendence: currentState.transcendence } : {}),
// Apotheosis data is eternal — never wiped by prestige
...(currentState.apotheosis ? { apotheosis: currentState.apotheosis } : {}),
// Story chapter progress is permanent — survives all resets
...(currentState.story ? { story: currentState.story } : {}),
};
return { newState, newPrestigeData, runestonesEarned, milestoneRunestones };
+2
View File
@@ -80,6 +80,8 @@ export const buildPostTranscendenceState = (
transcendence: newTranscendenceData,
// Apotheosis data is eternal — never wiped by transcendence
...(currentState.apotheosis ? { apotheosis: currentState.apotheosis } : {}),
// Story chapter progress is permanent — survives all resets
...(currentState.story ? { story: currentState.story } : {}),
};
return { newState, newTranscendenceData, echoesEarned };