feat: expansion coming-soon preview with save-safe display data

Add an expansion preview system so the Goddess and Vampire panels render
their full content (zones, bosses, thralls, achievements, etc.) even when the
expansion has not been unlocked, with all interactive elements visually
disabled.

- API /load now returns expansionPreview alongside game state, populated from
  initialGoddessState() and initialVampireState() — never part of the saved blob
- LoadResponse type updated with expansionPreview field
- gameContext exposes goddessPreview and vampirePreview, stored in separate
  state vars that never touch stateReference so saves are never polluted
- gameLayout applies expansion-preview CSS class when viewing a locked expansion
  with preview data available, and shows the coming-soon banner
- All 22 expansion panels updated to use state.vampire ?? vampirePreview and
  state.goddess ?? goddessPreview for display
- CSS disables all buttons/inputs/selects inside .expansion-preview
- apotheosis service patched to never auto-initialise goddess state — expansion
  remains locked until explicitly released
This commit is contained in:
2026-05-06 18:00:38 -07:00
committed by Naomi Carrigan
parent c6f4e27843
commit 397169e3dc
28 changed files with 204 additions and 90 deletions
+53 -21
View File
@@ -20,7 +20,11 @@ import {
import { Hono } from "hono"; import { Hono } from "hono";
import { defaultBosses } from "../data/bosses.js"; import { defaultBosses } from "../data/bosses.js";
import { defaultEquipmentSets } from "../data/equipmentSets.js"; import { defaultEquipmentSets } from "../data/equipmentSets.js";
import { initialGameState } from "../data/initialState.js"; import {
initialGameState,
initialGoddessState,
initialVampireState,
} from "../data/initialState.js";
import { dailyRewards } from "../data/loginBonus.js"; import { dailyRewards } from "../data/loginBonus.js";
import { defaultQuests } from "../data/quests.js"; import { defaultQuests } from "../data/quests.js";
import { currentSchemaVersion } from "../data/schemaVersion.js"; import { currentSchemaVersion } from "../data/schemaVersion.js";
@@ -1152,17 +1156,29 @@ gameRouter.get("/load", async(context) => {
const signature = secret === undefined const signature = secret === undefined
? undefined ? undefined
: computeHmac(JSON.stringify(freshState), secret); : computeHmac(JSON.stringify(freshState), secret);
const { inGuild, loginStreak } = playerRecord;
const loginBonus = null;
const offlineEssence = 0;
const offlineGold = 0;
const offlineSeconds = 0;
const schemaOutdated = false;
const state = freshState;
const expansionPreview = {
goddess: initialGoddessState(),
vampire: initialVampireState(),
};
return context.json({ return context.json({
currentSchemaVersion: currentSchemaVersion, currentSchemaVersion,
inGuild: playerRecord.inGuild, expansionPreview,
loginBonus: null, inGuild,
loginStreak: playerRecord.loginStreak, loginBonus,
offlineEssence: 0, loginStreak,
offlineGold: 0, offlineEssence,
offlineSeconds: 0, offlineGold,
schemaOutdated: false, offlineSeconds,
signature: signature, schemaOutdated,
state: freshState, signature,
state,
}); });
} }
@@ -1294,8 +1310,13 @@ gameRouter.get("/load", async(context) => {
? undefined ? undefined
: computeHmac(JSON.stringify(state), secret); : computeHmac(JSON.stringify(state), secret);
const inGuild = playerRecord?.inGuild ?? false; const inGuild = playerRecord?.inGuild ?? false;
const expansionPreview = {
goddess: initialGoddessState(),
vampire: initialVampireState(),
};
return context.json({ return context.json({
currentSchemaVersion, currentSchemaVersion,
expansionPreview,
inGuild, inGuild,
loginBonus, loginBonus,
loginStreak, loginStreak,
@@ -1524,17 +1545,28 @@ gameRouter.post("/reset", async(context) => {
const signature = secret === undefined const signature = secret === undefined
? undefined ? undefined
: computeHmac(JSON.stringify(freshState), secret); : computeHmac(JSON.stringify(freshState), secret);
const { loginStreak } = playerRecord;
const loginBonus = null;
const offlineEssence = 0;
const offlineGold = 0;
const offlineSeconds = 0;
const schemaOutdated = false;
const state = freshState;
const expansionPreview = {
goddess: initialGoddessState(),
vampire: initialVampireState(),
};
return context.json({ return context.json({
currentSchemaVersion: currentSchemaVersion, currentSchemaVersion,
loginBonus: null, expansionPreview,
loginStreak: playerRecord.loginStreak, loginBonus,
offlineEssence: 0, loginStreak,
offlineGold: 0, offlineEssence,
offlineSeconds: 0, offlineGold,
schemaOutdated: false, offlineSeconds,
signature: signature, schemaOutdated,
state: freshState, signature,
state,
}); });
} catch (error) { } catch (error) {
void logger.error( void logger.error(
+6 -8
View File
@@ -4,7 +4,7 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { initialGameState, initialGoddessState } from "../data/initialState.js"; import { initialGameState } from "../data/initialState.js";
import { import {
defaultTranscendenceUpgrades, defaultTranscendenceUpgrades,
} from "../data/transcendenceUpgrades.js"; } from "../data/transcendenceUpgrades.js";
@@ -48,13 +48,11 @@ const buildPostApotheosisState = (
const freshState = initialGameState(currentState.player, characterName); const freshState = initialGameState(currentState.player, characterName);
// Goddess state: initialised on first apotheosis, preserved on subsequent resets // Goddess state: preserved across resets; never auto-initialised (expansion not yet live)
let goddessSpread: object = {}; const goddessSpread: object
if (apotheosisCount === 1) { = currentState.goddess === undefined
goddessSpread = { goddess: initialGoddessState() }; ? {}
} else if (currentState.goddess !== undefined) { : { goddess: currentState.goddess };
goddessSpread = { goddess: currentState.goddess };
}
const updatedState: GameState = { const updatedState: GameState = {
...freshState, ...freshState,
@@ -308,6 +308,7 @@ const ConsecrationPanel = (): JSX.Element => {
buyConsecrationUpgrade, buyConsecrationUpgrade,
showConsecrationToast, showConsecrationToast,
dismissConsecrationToast, dismissConsecrationToast,
goddessPreview,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
@@ -327,7 +328,7 @@ const ConsecrationPanel = (): JSX.Element => {
); );
} }
const { goddess } = state; const goddess = state.goddess ?? goddessPreview;
if (goddess === undefined) { if (goddess === undefined) {
return ( return (
@@ -199,7 +199,7 @@ const DiscipleCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const DisciplesPanel = (): JSX.Element => { const DisciplesPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, goddessPreview } = useGame();
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => { const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_disciple_batch")); return parseBatchSize(localStorage.getItem("elysium_disciple_batch"));
}); });
@@ -212,7 +212,7 @@ const DisciplesPanel = (): JSX.Element => {
); );
} }
const goddessState = state.goddess; const goddessState = state.goddess ?? goddessPreview;
if (goddessState === undefined) { if (goddessState === undefined) {
return ( return (
<section className="panel"> <section className="panel">
@@ -207,6 +207,7 @@ const EnlightenmentPanel = (): JSX.Element => {
buyEnlightenmentUpgrade, buyEnlightenmentUpgrade,
showEnlightenmentToast, showEnlightenmentToast,
dismissEnlightenmentToast, dismissEnlightenmentToast,
goddessPreview,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
@@ -226,7 +227,7 @@ const EnlightenmentPanel = (): JSX.Element => {
); );
} }
const { goddess } = state; const goddess = state.goddess ?? goddessPreview;
if (goddess === undefined) { if (goddess === undefined) {
return ( return (
+21 -21
View File
@@ -231,6 +231,8 @@ const GameLayout = (): JSX.Element => {
loginBonus, loginBonus,
dismissLoginBonus, dismissLoginBonus,
schemaOutdated, schemaOutdated,
goddessPreview,
vampirePreview,
} = useGame(); } = useGame();
const [ activeMode, setActiveMode ] = useState<Mode>(readSavedMode); const [ activeMode, setActiveMode ] = useState<Mode>(readSavedMode);
const [ activeTab, setActiveTab ] = useState<Tab>("adventurers"); const [ activeTab, setActiveTab ] = useState<Tab>("adventurers");
@@ -276,6 +278,13 @@ const GameLayout = (): JSX.Element => {
const codexBadgeCount = pendingCodexEntryIds.length; const codexBadgeCount = pendingCodexEntryIds.length;
const storyBadgeCount = pendingStoryChapterIds.length; const storyBadgeCount = pendingStoryChapterIds.length;
const isGoddessPreview = activeMode === "goddess"
&& state.goddess === undefined
&& goddessPreview !== undefined;
const isVampirePreview = activeMode === "vampire"
&& state.vampire === undefined
&& vampirePreview !== undefined;
const isExpansionPreview = isGoddessPreview || isVampirePreview;
function handleOpenEditProfile(): void { function handleOpenEditProfile(): void {
setEditingProfile(true); setEditingProfile(true);
@@ -342,38 +351,20 @@ const GameLayout = (): JSX.Element => {
<main className="game-content"> <main className="game-content">
<nav className="mode-bar"> <nav className="mode-bar">
{modes.map((mode) => { {modes.map((mode) => {
const apotheosisCount = state.apotheosis?.count ?? 0;
const eternalSovereigntyCount
= state.vampire?.eternalSovereignty.count ?? 0;
const vampireLocked
= mode === "vampire" && apotheosisCount === 0;
const goddessLocked
= mode === "goddess" && eternalSovereigntyCount === 0;
const isLocked = vampireLocked || goddessLocked;
function handleModeClick(): void { function handleModeClick(): void {
if (!isLocked) { handleSetMode(mode);
handleSetMode(mode);
}
} }
return ( return (
<button <button
className={`mode-button${activeMode === mode className={`mode-button${activeMode === mode
? " active" ? " active"
: ""}${isLocked
? " locked"
: ""}`} : ""}`}
disabled={isLocked}
key={mode} key={mode}
onClick={handleModeClick} onClick={handleModeClick}
title={isLocked title={modeLabels[mode]}
? "Not yet unlocked"
: modeLabels[mode]}
type="button" type="button"
> >
{modeLabels[mode]} {modeLabels[mode]}
{isLocked
? <span className="mode-lock">{"🔒"}</span>
: null}
</button> </button>
); );
})} })}
@@ -450,7 +441,16 @@ const GameLayout = (): JSX.Element => {
</nav> </nav>
} }
<div className="tab-content"> <div className={`tab-content${
isExpansionPreview
? " expansion-preview"
: ""
}`}>
{activeMode !== "mortal"
&& <div className="expansion-coming-soon">
<p>{"✨ Expansion Coming Soon~"}</p>
</div>
}
{activeMode === "mortal" && activeTab === "adventurers" {activeMode === "mortal" && activeTab === "adventurers"
&& <AdventurerPanel />} && <AdventurerPanel />}
{activeMode === "mortal" && activeTab === "upgrades" {activeMode === "mortal" && activeTab === "upgrades"
@@ -178,7 +178,7 @@ const GoddessAchievementCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const GoddessAchievementsPanel = (): JSX.Element => { const GoddessAchievementsPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, goddessPreview } = useGame();
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
if (state === null) { if (state === null) {
@@ -189,7 +189,7 @@ const GoddessAchievementsPanel = (): JSX.Element => {
); );
} }
const { goddess } = state; const goddess = state.goddess ?? goddessPreview;
if (goddess === undefined) { if (goddess === undefined) {
return ( return (
@@ -305,6 +305,7 @@ const GoddessBossPanel = (): JSX.Element => {
dismissGoddessBattle, dismissGoddessBattle,
formatNumber, formatNumber,
formatInteger, formatInteger,
goddessPreview,
} = useGame(); } = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>( const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
@@ -322,7 +323,7 @@ const GoddessBossPanel = (): JSX.Element => {
); );
} }
const { goddess } = state; const goddess = state.goddess ?? goddessPreview;
if (goddess === undefined) { if (goddess === undefined) {
return ( return (
@@ -6,6 +6,7 @@
*/ */
/* 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-nested-callbacks -- Nested recipe/material maps require nesting */ /* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */
/* eslint-disable complexity -- Expansion preview fallback adds necessary branching */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
@@ -25,7 +26,7 @@ const bonusLabel: Record<string, string> = {
* @returns The JSX element. * @returns The JSX element.
*/ */
const GoddessCraftingPanel = (): JSX.Element => { const GoddessCraftingPanel = (): JSX.Element => {
const { state, craftGoddessRecipe, formatNumber } = useGame(); const { state, craftGoddessRecipe, formatNumber, goddessPreview } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => { const [ activeZoneId, setActiveZoneId ] = useState(() => {
return ( return (
sessionStorage.getItem("elysium_goddess_craft_zone") sessionStorage.getItem("elysium_goddess_craft_zone")
@@ -42,7 +43,7 @@ const GoddessCraftingPanel = (): JSX.Element => {
); );
} }
const { goddess } = state; const goddess = state.goddess ?? goddessPreview;
const playerMaterials = goddess?.exploration.materials ?? []; const playerMaterials = goddess?.exploration.materials ?? [];
const craftedIds = goddess?.exploration.craftedRecipeIds ?? []; const craftedIds = goddess?.exploration.craftedRecipeIds ?? [];
@@ -191,7 +191,7 @@ type TabFilter = "all" | GoddessEquipmentType;
* @returns The JSX element. * @returns The JSX element.
*/ */
export const GoddessEquipmentPanel = (): JSX.Element => { export const GoddessEquipmentPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, goddessPreview } = useGame();
const [ activeTab, setActiveTab ] = useState<TabFilter>("all"); const [ activeTab, setActiveTab ] = useState<TabFilter>("all");
if (state === null) { if (state === null) {
@@ -202,7 +202,8 @@ export const GoddessEquipmentPanel = (): JSX.Element => {
const divinity = state.resources.divinity ?? 0; const divinity = state.resources.divinity ?? 0;
const stardust = state.resources.stardust ?? 0; const stardust = state.resources.stardust ?? 0;
const equipment = state.goddess?.equipment ?? []; const goddess = state.goddess ?? goddessPreview;
const equipment = goddess?.equipment ?? [];
const filteredEquipment = activeTab === "all" const filteredEquipment = activeTab === "all"
? equipment ? equipment
@@ -85,6 +85,7 @@ const GoddessExplorationPanel = (): JSX.Element => {
startGoddessExploration, startGoddessExploration,
collectGoddessExploration, collectGoddessExploration,
formatNumber, formatNumber,
goddessPreview,
} = useGame(); } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => { const [ activeZoneId, setActiveZoneId ] = useState(() => {
return ( return (
@@ -160,7 +161,7 @@ const GoddessExplorationPanel = (): JSX.Element => {
); );
} }
const { goddess } = state; const goddess = state.goddess ?? goddessPreview;
const explorationState = goddess?.exploration; const explorationState = goddess?.exploration;
const goddessZones = goddess?.zones ?? []; const goddessZones = goddess?.zones ?? [];
@@ -141,7 +141,7 @@ const GoddessQuestCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const GoddessQuestsPanel = (): JSX.Element => { const GoddessQuestsPanel = (): JSX.Element => {
const { state } = useGame(); const { state, goddessPreview } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => { const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_goddess_quest_zone") return sessionStorage.getItem("elysium_goddess_quest_zone")
?? "goddess_celestial_garden"; ?? "goddess_celestial_garden";
@@ -155,7 +155,7 @@ const GoddessQuestsPanel = (): JSX.Element => {
); );
} }
const goddessState = state.goddess; const goddessState = state.goddess ?? goddessPreview;
if (goddessState === undefined) { if (goddessState === undefined) {
return ( return (
<section className="panel"> <section className="panel">
@@ -164,7 +164,7 @@ const GoddessUpgradeCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
export const GoddessUpgradesPanel = (): JSX.Element => { export const GoddessUpgradesPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, goddessPreview } = useGame();
if (state === null) { if (state === null) {
return <div className="panel"><p>{"Loading..."}</p></div>; return <div className="panel"><p>{"Loading..."}</p></div>;
@@ -174,7 +174,8 @@ export const GoddessUpgradesPanel = (): JSX.Element => {
const divinity = state.resources.divinity ?? 0; const divinity = state.resources.divinity ?? 0;
const stardust = state.resources.stardust ?? 0; const stardust = state.resources.stardust ?? 0;
const upgrades = state.goddess?.upgrades ?? []; const goddess = state.goddess ?? goddessPreview;
const upgrades = goddess?.upgrades ?? [];
const purchased = upgrades.filter((upgrade) => { const purchased = upgrades.filter((upgrade) => {
return upgrade.purchased; return upgrade.purchased;
@@ -81,7 +81,7 @@ const GoddessZoneCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const GoddessZonesPanel = (): JSX.Element => { const GoddessZonesPanel = (): JSX.Element => {
const { state } = useGame(); const { state, goddessPreview } = useGame();
if (state === null) { if (state === null) {
return ( return (
@@ -91,7 +91,7 @@ const GoddessZonesPanel = (): JSX.Element => {
); );
} }
const { goddess } = state; const goddess = state.goddess ?? goddessPreview;
if (goddess === undefined) { if (goddess === undefined) {
return ( return (
@@ -178,7 +178,7 @@ const VampireAchievementCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const VampireAchievementsPanel = (): JSX.Element => { const VampireAchievementsPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, vampirePreview } = useGame();
const [ showLocked, setShowLocked ] = useState(true); const [ showLocked, setShowLocked ] = useState(true);
if (state === null) { if (state === null) {
@@ -189,7 +189,7 @@ const VampireAchievementsPanel = (): JSX.Element => {
); );
} }
const { vampire } = state; const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -189,6 +189,7 @@ const VampireAwakeningPanel = (): JSX.Element => {
formatInteger, formatInteger,
awaken, awaken,
buyAwakeningUpgrade, buyAwakeningUpgrade,
vampirePreview,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
@@ -208,7 +209,7 @@ const VampireAwakeningPanel = (): JSX.Element => {
); );
} }
const { vampire } = state; const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -306,6 +306,7 @@ const VampireBossPanel = (): JSX.Element => {
dismissVampireBattle, dismissVampireBattle,
formatNumber, formatNumber,
formatInteger, formatInteger,
vampirePreview,
} = useGame(); } = useGame();
const [ challengingBossId, setChallengingBossId ] = useState<string | null>( const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
@@ -323,7 +324,7 @@ const VampireBossPanel = (): JSX.Element => {
); );
} }
const { vampire } = state; const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -24,7 +24,7 @@ const bonusLabel: Record<string, string> = {
* @returns The JSX element. * @returns The JSX element.
*/ */
const VampireCraftingPanel = (): JSX.Element => { const VampireCraftingPanel = (): JSX.Element => {
const { state, craftVampireRecipe, formatNumber } = useGame(); const { state, craftVampireRecipe, formatNumber, vampirePreview } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => { const [ activeZoneId, setActiveZoneId ] = useState(() => {
return ( return (
sessionStorage.getItem("elysium_vampire_craft_zone") sessionStorage.getItem("elysium_vampire_craft_zone")
@@ -41,7 +41,7 @@ const VampireCraftingPanel = (): JSX.Element => {
); );
} }
const { vampire } = state; const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -192,7 +192,7 @@ type TabFilter = "all" | VampireEquipmentType;
* @returns The JSX element. * @returns The JSX element.
*/ */
const VampireEquipmentPanel = (): JSX.Element => { const VampireEquipmentPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, vampirePreview } = useGame();
const [ activeTab, setActiveTab ] = useState<TabFilter>("all"); const [ activeTab, setActiveTab ] = useState<TabFilter>("all");
if (state === null) { if (state === null) {
@@ -203,7 +203,8 @@ const VampireEquipmentPanel = (): JSX.Element => {
); );
} }
const { resources, vampire } = state; const { resources } = state;
const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -85,6 +85,7 @@ const VampireExplorationPanel = (): JSX.Element => {
startVampireExploration, startVampireExploration,
collectVampireExploration, collectVampireExploration,
formatNumber, formatNumber,
vampirePreview,
} = useGame(); } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => { const [ activeZoneId, setActiveZoneId ] = useState(() => {
return ( return (
@@ -160,7 +161,7 @@ const VampireExplorationPanel = (): JSX.Element => {
); );
} }
const { vampire } = state; const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -6,6 +6,7 @@
*/ */
/* 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 react/no-multi-comp -- QuestCard sub-component is tightly coupled */ /* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
/* eslint-disable complexity -- Expansion preview fallback adds necessary branching */
import { useState, type JSX } from "react"; import { useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import type { import type {
@@ -140,7 +141,7 @@ const VampireQuestCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const VampireQuestsPanel = (): JSX.Element => { const VampireQuestsPanel = (): JSX.Element => {
const { state, toggleVampireAutoQuest } = useGame(); const { state, toggleVampireAutoQuest, vampirePreview } = useGame();
const [ activeZoneId, setActiveZoneId ] = useState(() => { const [ activeZoneId, setActiveZoneId ] = useState(() => {
return sessionStorage.getItem("elysium_vampire_quest_zone") return sessionStorage.getItem("elysium_vampire_quest_zone")
?? "vampire_haunted_catacombs"; ?? "vampire_haunted_catacombs";
@@ -154,7 +155,7 @@ const VampireQuestsPanel = (): JSX.Element => {
); );
} }
const vampireState = state.vampire; const vampireState = state.vampire ?? vampirePreview;
if (vampireState === undefined) { if (vampireState === undefined) {
return ( return (
<section className="panel"> <section className="panel">
@@ -305,6 +305,7 @@ const VampireSiringPanel = (): JSX.Element => {
formatNumber, formatNumber,
sire, sire,
buySiringUpgrade, buySiringUpgrade,
vampirePreview,
} = useGame(); } = useGame();
const [ isPending, setIsPending ] = useState(false); const [ isPending, setIsPending ] = useState(false);
@@ -324,7 +325,7 @@ const VampireSiringPanel = (): JSX.Element => {
); );
} }
const { vampire } = state; const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -198,7 +198,9 @@ const ThrallCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const VampireThrallsPanel = (): JSX.Element => { const VampireThrallsPanel = (): JSX.Element => {
const { state, formatNumber, toggleVampireAutoThrall } = useGame(); const {
state, formatNumber, toggleVampireAutoThrall, vampirePreview,
} = useGame();
const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => { const [ selectedBatch, setSelectedBatch ] = useState<BatchSize>(() => {
return parseBatchSize(localStorage.getItem("elysium_thrall_batch")); return parseBatchSize(localStorage.getItem("elysium_thrall_batch"));
}); });
@@ -211,7 +213,7 @@ const VampireThrallsPanel = (): JSX.Element => {
); );
} }
const vampireState = state.vampire; const vampireState = state.vampire ?? vampirePreview;
if (vampireState === undefined) { if (vampireState === undefined) {
return ( return (
<section className="panel"> <section className="panel">
@@ -167,7 +167,7 @@ const VampireUpgradeCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const VampireUpgradesPanel = (): JSX.Element => { const VampireUpgradesPanel = (): JSX.Element => {
const { state, formatNumber } = useGame(); const { state, formatNumber, vampirePreview } = useGame();
if (state === null) { if (state === null) {
return ( return (
@@ -177,7 +177,8 @@ const VampireUpgradesPanel = (): JSX.Element => {
); );
} }
const { resources, vampire } = state; const { resources } = state;
const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
@@ -81,7 +81,7 @@ const VampireZoneCard = ({
* @returns The JSX element. * @returns The JSX element.
*/ */
const VampireZonesPanel = (): JSX.Element => { const VampireZonesPanel = (): JSX.Element => {
const { state } = useGame(); const { state, vampirePreview } = useGame();
if (state === null) { if (state === null) {
return ( return (
@@ -91,7 +91,7 @@ const VampireZonesPanel = (): JSX.Element => {
); );
} }
const { vampire } = state; const vampire = state.vampire ?? vampirePreview;
if (vampire === undefined) { if (vampire === undefined) {
return ( return (
+36 -4
View File
@@ -15,6 +15,7 @@ import {
STORY_CHAPTERS, STORY_CHAPTERS,
type Achievement, type Achievement,
type ApotheosisResponse, type ApotheosisResponse,
type AwakeningResponse,
type BossChallengeResponse, type BossChallengeResponse,
type ConsecrationResponse, type ConsecrationResponse,
type EnlightenmentResponse, type EnlightenmentResponse,
@@ -22,14 +23,15 @@ import {
type GameState, type GameState,
type GoddessBossChallengeResponse, type GoddessBossChallengeResponse,
type GoddessExploreCollectResponse, type GoddessExploreCollectResponse,
type AwakeningResponse, type GoddessState,
type SiringResponse,
type VampireBossChallengeResponse,
type VampireExploreCollectResponse,
type LoginBonusResult, type LoginBonusResult,
type NumberFormat, type NumberFormat,
type Quest, type Quest,
type SiringResponse,
type TranscendenceResponse, type TranscendenceResponse,
type VampireBossChallengeResponse,
type VampireExploreCollectResponse,
type VampireState,
computeUnlockedCompanionIds, computeUnlockedCompanionIds,
isStoryChapterUnlocked, isStoryChapterUnlocked,
} from "@elysium/types"; } from "@elysium/types";
@@ -865,6 +867,18 @@ interface GameContextValue {
* Toggle the vampire auto-thrall setting on/off. * Toggle the vampire auto-thrall setting on/off.
*/ */
toggleVampireAutoThrall: ()=> void; toggleVampireAutoThrall: ()=> void;
/**
* Initial goddess state for expansion preview display.
* Never saved to game state.
*/
goddessPreview: GoddessState | undefined;
/**
* Initial vampire state for expansion preview display.
* Never saved to game state.
*/
vampirePreview: VampireState | undefined;
} }
export interface BattleResult { export interface BattleResult {
@@ -953,6 +967,12 @@ export const GameProvider = ({
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
const [ inGuild, setInGuild ] = useState(false); const [ inGuild, setInGuild ] = useState(false);
const [ goddessPreview, setGoddessPreview ] = useState<
GoddessState | undefined
>(undefined);
const [ vampirePreview, setVampirePreview ] = useState<
VampireState | undefined
>(undefined);
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
Array<string> Array<string>
>([]); >([]);
@@ -991,6 +1011,8 @@ export const GameProvider = ({
setSaveSchemaVersion(data.state.schemaVersion ?? 0); setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion); setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild); setInGuild(data.inGuild);
setGoddessPreview(data.expansionPreview.goddess);
setVampirePreview(data.expansionPreview.vampire);
// Fetch number format preference from profile (fire-and-forget, non-blocking) // Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`). void fetch(`/api/profile/${data.state.player.discordId}`).
@@ -1051,6 +1073,8 @@ export const GameProvider = ({
setSaveSchemaVersion(data.state.schemaVersion ?? 0); setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion); setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild); setInGuild(data.inGuild);
setGoddessPreview(data.expansionPreview.goddess);
setVampirePreview(data.expansionPreview.vampire);
} catch (error_: unknown) { } catch (error_: unknown) {
setError( setError(
error_ instanceof Error error_ instanceof Error
@@ -3577,6 +3601,8 @@ export const GameProvider = ({
setOfflineGold(0); setOfflineGold(0);
setOfflineEssence(0); setOfflineEssence(0);
setLoginBonus(null); setLoginBonus(null);
setGoddessPreview(data.expansionPreview.goddess);
setVampirePreview(data.expansionPreview.vampire);
if (data.signature !== undefined) { if (data.signature !== undefined) {
signatureReference.current = data.signature; signatureReference.current = data.signature;
localStorage.setItem("elysium_save_signature", data.signature); localStorage.setItem("elysium_save_signature", data.signature);
@@ -3697,6 +3723,8 @@ export const GameProvider = ({
setOfflineGold(0); setOfflineGold(0);
setOfflineEssence(0); setOfflineEssence(0);
setLoginBonus(null); setLoginBonus(null);
setGoddessPreview(data.expansionPreview.goddess);
setVampirePreview(data.expansionPreview.vampire);
if (data.signature !== undefined) { if (data.signature !== undefined) {
signatureReference.current = data.signature; signatureReference.current = data.signature;
localStorage.setItem("elysium_save_signature", data.signature); localStorage.setItem("elysium_save_signature", data.signature);
@@ -3796,6 +3824,7 @@ export const GameProvider = ({
formatInteger, formatInteger,
formatNumber, formatNumber,
goddessBattleResult, goddessBattleResult,
goddessPreview,
handleClick, handleClick,
inGuild, inGuild,
isLoading, isLoading,
@@ -3841,6 +3870,7 @@ export const GameProvider = ({
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
vampireBattleResult, vampireBattleResult,
vampirePreview,
}; };
}, [ }, [
apotheosis, apotheosis,
@@ -3907,6 +3937,7 @@ export const GameProvider = ({
formatInteger, formatInteger,
formatNumber, formatNumber,
goddessBattleResult, goddessBattleResult,
goddessPreview,
handleClick, handleClick,
inGuild, inGuild,
isLoading, isLoading,
@@ -3951,6 +3982,7 @@ export const GameProvider = ({
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
vampireBattleResult, vampireBattleResult,
vampirePreview,
]); ]);
return ( return (
+26
View File
@@ -5415,3 +5415,29 @@ body.vampire-mode {
justify-content: center; justify-content: center;
min-height: 200px; min-height: 200px;
} }
/* ===================== EXPANSION COMING SOON ===================== */
.expansion-coming-soon {
background: linear-gradient(
135deg,
var(--colour-surface),
var(--colour-surface-2)
);
border: 2px solid var(--colour-accent);
border-radius: var(--radius-lg);
color: var(--colour-accent-light);
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 1.5rem;
padding: 1rem 1.5rem;
text-align: center;
}
/* ===================== EXPANSION PREVIEW ===================== */
.expansion-preview button,
.expansion-preview input,
.expansion-preview select {
cursor: not-allowed;
opacity: 0.4;
pointer-events: none;
}
+10
View File
@@ -11,9 +11,11 @@ import type {
EquipmentType, EquipmentType,
} from "./equipment.js"; } from "./equipment.js";
import type { GameState } from "./gameState.js"; import type { GameState } from "./gameState.js";
import type { GoddessState } from "./goddessState.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"; import type { CompletedChapter } from "./story.js";
import type { VampireState } from "./vampireState.js";
interface AuthResponse { interface AuthResponse {
token: string; token: string;
@@ -114,6 +116,14 @@ interface LoadResponse {
* The current expected schema version from the server. * The current expected schema version from the server.
*/ */
currentSchemaVersion: number; currentSchemaVersion: number;
/**
* Initial expansion states for preview display never saved to game state.
*/
expansionPreview: {
goddess: GoddessState;
vampire: VampireState;
};
} }
interface BossChallengeRequest { interface BossChallengeRequest {