From 397169e3dcbe17b8d625663eecd15c7002499ca6 Mon Sep 17 00:00:00 2001 From: Hikari Date: Wed, 6 May 2026 18:00:38 -0700 Subject: [PATCH] feat: expansion coming-soon preview with save-safe display data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/routes/game.ts | 74 +++++++++++++------ apps/api/src/services/apotheosis.ts | 14 ++-- .../src/components/game/consecrationPanel.tsx | 3 +- .../src/components/game/disciplesPanel.tsx | 4 +- .../components/game/enlightenmentPanel.tsx | 3 +- apps/web/src/components/game/gameLayout.tsx | 42 +++++------ .../game/goddessAchievementsPanel.tsx | 4 +- .../src/components/game/goddessBossPanel.tsx | 3 +- .../components/game/goddessCraftingPanel.tsx | 5 +- .../components/game/goddessEquipmentPanel.tsx | 5 +- .../game/goddessExplorationPanel.tsx | 3 +- .../components/game/goddessQuestsPanel.tsx | 4 +- .../components/game/goddessUpgradesPanel.tsx | 5 +- .../src/components/game/goddessZonesPanel.tsx | 4 +- .../game/vampireAchievementsPanel.tsx | 4 +- .../components/game/vampireAwakeningPanel.tsx | 3 +- .../src/components/game/vampireBossPanel.tsx | 3 +- .../components/game/vampireCraftingPanel.tsx | 4 +- .../components/game/vampireEquipmentPanel.tsx | 5 +- .../game/vampireExplorationPanel.tsx | 3 +- .../components/game/vampireQuestsPanel.tsx | 5 +- .../components/game/vampireSiringPanel.tsx | 3 +- .../components/game/vampireThrallsPanel.tsx | 6 +- .../components/game/vampireUpgradesPanel.tsx | 5 +- .../src/components/game/vampireZonesPanel.tsx | 4 +- apps/web/src/context/gameContext.tsx | 40 +++++++++- apps/web/src/styles.css | 26 +++++++ packages/types/src/interfaces/api.ts | 10 +++ 28 files changed, 204 insertions(+), 90 deletions(-) diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 575e6ce..9a92e32 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -20,7 +20,11 @@ import { import { Hono } from "hono"; import { defaultBosses } from "../data/bosses.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 { defaultQuests } from "../data/quests.js"; import { currentSchemaVersion } from "../data/schemaVersion.js"; @@ -1152,17 +1156,29 @@ gameRouter.get("/load", async(context) => { const signature = secret === undefined ? undefined : 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({ - currentSchemaVersion: currentSchemaVersion, - inGuild: playerRecord.inGuild, - loginBonus: null, - loginStreak: playerRecord.loginStreak, - offlineEssence: 0, - offlineGold: 0, - offlineSeconds: 0, - schemaOutdated: false, - signature: signature, - state: freshState, + currentSchemaVersion, + expansionPreview, + inGuild, + loginBonus, + loginStreak, + offlineEssence, + offlineGold, + offlineSeconds, + schemaOutdated, + signature, + state, }); } @@ -1294,8 +1310,13 @@ gameRouter.get("/load", async(context) => { ? undefined : computeHmac(JSON.stringify(state), secret); const inGuild = playerRecord?.inGuild ?? false; + const expansionPreview = { + goddess: initialGoddessState(), + vampire: initialVampireState(), + }; return context.json({ currentSchemaVersion, + expansionPreview, inGuild, loginBonus, loginStreak, @@ -1524,17 +1545,28 @@ gameRouter.post("/reset", async(context) => { const signature = secret === undefined ? undefined : 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({ - currentSchemaVersion: currentSchemaVersion, - loginBonus: null, - loginStreak: playerRecord.loginStreak, - offlineEssence: 0, - offlineGold: 0, - offlineSeconds: 0, - schemaOutdated: false, - signature: signature, - state: freshState, + currentSchemaVersion, + expansionPreview, + loginBonus, + loginStreak, + offlineEssence, + offlineGold, + offlineSeconds, + schemaOutdated, + signature, + state, }); } catch (error) { void logger.error( diff --git a/apps/api/src/services/apotheosis.ts b/apps/api/src/services/apotheosis.ts index a628d53..4bba0f5 100644 --- a/apps/api/src/services/apotheosis.ts +++ b/apps/api/src/services/apotheosis.ts @@ -4,7 +4,7 @@ * @license Naomi's Public License * @author Naomi Carrigan */ -import { initialGameState, initialGoddessState } from "../data/initialState.js"; +import { initialGameState } from "../data/initialState.js"; import { defaultTranscendenceUpgrades, } from "../data/transcendenceUpgrades.js"; @@ -48,13 +48,11 @@ const buildPostApotheosisState = ( const freshState = initialGameState(currentState.player, characterName); - // Goddess state: initialised on first apotheosis, preserved on subsequent resets - let goddessSpread: object = {}; - if (apotheosisCount === 1) { - goddessSpread = { goddess: initialGoddessState() }; - } else if (currentState.goddess !== undefined) { - goddessSpread = { goddess: currentState.goddess }; - } + // Goddess state: preserved across resets; never auto-initialised (expansion not yet live) + const goddessSpread: object + = currentState.goddess === undefined + ? {} + : { goddess: currentState.goddess }; const updatedState: GameState = { ...freshState, diff --git a/apps/web/src/components/game/consecrationPanel.tsx b/apps/web/src/components/game/consecrationPanel.tsx index c7215b7..9bcb278 100644 --- a/apps/web/src/components/game/consecrationPanel.tsx +++ b/apps/web/src/components/game/consecrationPanel.tsx @@ -308,6 +308,7 @@ const ConsecrationPanel = (): JSX.Element => { buyConsecrationUpgrade, showConsecrationToast, dismissConsecrationToast, + goddessPreview, } = useGame(); const [ isPending, setIsPending ] = useState(false); @@ -327,7 +328,7 @@ const ConsecrationPanel = (): JSX.Element => { ); } - const { goddess } = state; + const goddess = state.goddess ?? goddessPreview; if (goddess === undefined) { return ( diff --git a/apps/web/src/components/game/disciplesPanel.tsx b/apps/web/src/components/game/disciplesPanel.tsx index 393e5cc..239a25b 100644 --- a/apps/web/src/components/game/disciplesPanel.tsx +++ b/apps/web/src/components/game/disciplesPanel.tsx @@ -199,7 +199,7 @@ const DiscipleCard = ({ * @returns The JSX element. */ const DisciplesPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, goddessPreview } = useGame(); const [ selectedBatch, setSelectedBatch ] = useState(() => { 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) { return (
diff --git a/apps/web/src/components/game/enlightenmentPanel.tsx b/apps/web/src/components/game/enlightenmentPanel.tsx index 6821be1..ad2c6b0 100644 --- a/apps/web/src/components/game/enlightenmentPanel.tsx +++ b/apps/web/src/components/game/enlightenmentPanel.tsx @@ -207,6 +207,7 @@ const EnlightenmentPanel = (): JSX.Element => { buyEnlightenmentUpgrade, showEnlightenmentToast, dismissEnlightenmentToast, + goddessPreview, } = useGame(); const [ isPending, setIsPending ] = useState(false); @@ -226,7 +227,7 @@ const EnlightenmentPanel = (): JSX.Element => { ); } - const { goddess } = state; + const goddess = state.goddess ?? goddessPreview; if (goddess === undefined) { return ( diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 7d1610a..d2550ee 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -231,6 +231,8 @@ const GameLayout = (): JSX.Element => { loginBonus, dismissLoginBonus, schemaOutdated, + goddessPreview, + vampirePreview, } = useGame(); const [ activeMode, setActiveMode ] = useState(readSavedMode); const [ activeTab, setActiveTab ] = useState("adventurers"); @@ -276,6 +278,13 @@ const GameLayout = (): JSX.Element => { const codexBadgeCount = pendingCodexEntryIds.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 { setEditingProfile(true); @@ -342,38 +351,20 @@ const GameLayout = (): JSX.Element => {
} -
+
+ {activeMode !== "mortal" + &&
+

{"✨ Expansion Coming Soon~"}

+
+ } {activeMode === "mortal" && activeTab === "adventurers" && } {activeMode === "mortal" && activeTab === "upgrades" diff --git a/apps/web/src/components/game/goddessAchievementsPanel.tsx b/apps/web/src/components/game/goddessAchievementsPanel.tsx index 9362433..68939fe 100644 --- a/apps/web/src/components/game/goddessAchievementsPanel.tsx +++ b/apps/web/src/components/game/goddessAchievementsPanel.tsx @@ -178,7 +178,7 @@ const GoddessAchievementCard = ({ * @returns The JSX element. */ const GoddessAchievementsPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, goddessPreview } = useGame(); const [ showLocked, setShowLocked ] = useState(true); if (state === null) { @@ -189,7 +189,7 @@ const GoddessAchievementsPanel = (): JSX.Element => { ); } - const { goddess } = state; + const goddess = state.goddess ?? goddessPreview; if (goddess === undefined) { return ( diff --git a/apps/web/src/components/game/goddessBossPanel.tsx b/apps/web/src/components/game/goddessBossPanel.tsx index 7c5fd16..b671381 100644 --- a/apps/web/src/components/game/goddessBossPanel.tsx +++ b/apps/web/src/components/game/goddessBossPanel.tsx @@ -305,6 +305,7 @@ const GoddessBossPanel = (): JSX.Element => { dismissGoddessBattle, formatNumber, formatInteger, + goddessPreview, } = useGame(); const [ challengingBossId, setChallengingBossId ] = useState( @@ -322,7 +323,7 @@ const GoddessBossPanel = (): JSX.Element => { ); } - const { goddess } = state; + const goddess = state.goddess ?? goddessPreview; if (goddess === undefined) { return ( diff --git a/apps/web/src/components/game/goddessCraftingPanel.tsx b/apps/web/src/components/game/goddessCraftingPanel.tsx index b7cb9a1..e9b91f5 100644 --- a/apps/web/src/components/game/goddessCraftingPanel.tsx +++ b/apps/web/src/components/game/goddessCraftingPanel.tsx @@ -6,6 +6,7 @@ */ /* 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 complexity -- Expansion preview fallback adds necessary branching */ import { type JSX, useState } from "react"; import { useGame } from "../../context/gameContext.js"; @@ -25,7 +26,7 @@ const bonusLabel: Record = { * @returns The JSX element. */ const GoddessCraftingPanel = (): JSX.Element => { - const { state, craftGoddessRecipe, formatNumber } = useGame(); + const { state, craftGoddessRecipe, formatNumber, goddessPreview } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState(() => { return ( 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 craftedIds = goddess?.exploration.craftedRecipeIds ?? []; diff --git a/apps/web/src/components/game/goddessEquipmentPanel.tsx b/apps/web/src/components/game/goddessEquipmentPanel.tsx index 0e915fe..abac683 100644 --- a/apps/web/src/components/game/goddessEquipmentPanel.tsx +++ b/apps/web/src/components/game/goddessEquipmentPanel.tsx @@ -191,7 +191,7 @@ type TabFilter = "all" | GoddessEquipmentType; * @returns The JSX element. */ export const GoddessEquipmentPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, goddessPreview } = useGame(); const [ activeTab, setActiveTab ] = useState("all"); if (state === null) { @@ -202,7 +202,8 @@ export const GoddessEquipmentPanel = (): JSX.Element => { const divinity = state.resources.divinity ?? 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" ? equipment diff --git a/apps/web/src/components/game/goddessExplorationPanel.tsx b/apps/web/src/components/game/goddessExplorationPanel.tsx index 451313c..ae2d3da 100644 --- a/apps/web/src/components/game/goddessExplorationPanel.tsx +++ b/apps/web/src/components/game/goddessExplorationPanel.tsx @@ -85,6 +85,7 @@ const GoddessExplorationPanel = (): JSX.Element => { startGoddessExploration, collectGoddessExploration, formatNumber, + goddessPreview, } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState(() => { return ( @@ -160,7 +161,7 @@ const GoddessExplorationPanel = (): JSX.Element => { ); } - const { goddess } = state; + const goddess = state.goddess ?? goddessPreview; const explorationState = goddess?.exploration; const goddessZones = goddess?.zones ?? []; diff --git a/apps/web/src/components/game/goddessQuestsPanel.tsx b/apps/web/src/components/game/goddessQuestsPanel.tsx index 2dc233f..6846c4f 100644 --- a/apps/web/src/components/game/goddessQuestsPanel.tsx +++ b/apps/web/src/components/game/goddessQuestsPanel.tsx @@ -141,7 +141,7 @@ const GoddessQuestCard = ({ * @returns The JSX element. */ const GoddessQuestsPanel = (): JSX.Element => { - const { state } = useGame(); + const { state, goddessPreview } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState(() => { return sessionStorage.getItem("elysium_goddess_quest_zone") ?? "goddess_celestial_garden"; @@ -155,7 +155,7 @@ const GoddessQuestsPanel = (): JSX.Element => { ); } - const goddessState = state.goddess; + const goddessState = state.goddess ?? goddessPreview; if (goddessState === undefined) { return (
diff --git a/apps/web/src/components/game/goddessUpgradesPanel.tsx b/apps/web/src/components/game/goddessUpgradesPanel.tsx index 949516b..919ff0c 100644 --- a/apps/web/src/components/game/goddessUpgradesPanel.tsx +++ b/apps/web/src/components/game/goddessUpgradesPanel.tsx @@ -164,7 +164,7 @@ const GoddessUpgradeCard = ({ * @returns The JSX element. */ export const GoddessUpgradesPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, goddessPreview } = useGame(); if (state === null) { return

{"Loading..."}

; @@ -174,7 +174,8 @@ export const GoddessUpgradesPanel = (): JSX.Element => { const divinity = state.resources.divinity ?? 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) => { return upgrade.purchased; diff --git a/apps/web/src/components/game/goddessZonesPanel.tsx b/apps/web/src/components/game/goddessZonesPanel.tsx index 9c98de1..4e03ba9 100644 --- a/apps/web/src/components/game/goddessZonesPanel.tsx +++ b/apps/web/src/components/game/goddessZonesPanel.tsx @@ -81,7 +81,7 @@ const GoddessZoneCard = ({ * @returns The JSX element. */ const GoddessZonesPanel = (): JSX.Element => { - const { state } = useGame(); + const { state, goddessPreview } = useGame(); if (state === null) { return ( @@ -91,7 +91,7 @@ const GoddessZonesPanel = (): JSX.Element => { ); } - const { goddess } = state; + const goddess = state.goddess ?? goddessPreview; if (goddess === undefined) { return ( diff --git a/apps/web/src/components/game/vampireAchievementsPanel.tsx b/apps/web/src/components/game/vampireAchievementsPanel.tsx index d644b8f..a5449c0 100644 --- a/apps/web/src/components/game/vampireAchievementsPanel.tsx +++ b/apps/web/src/components/game/vampireAchievementsPanel.tsx @@ -178,7 +178,7 @@ const VampireAchievementCard = ({ * @returns The JSX element. */ const VampireAchievementsPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, vampirePreview } = useGame(); const [ showLocked, setShowLocked ] = useState(true); if (state === null) { @@ -189,7 +189,7 @@ const VampireAchievementsPanel = (): JSX.Element => { ); } - const { vampire } = state; + const vampire = state.vampire ?? vampirePreview; if (vampire === undefined) { return ( diff --git a/apps/web/src/components/game/vampireAwakeningPanel.tsx b/apps/web/src/components/game/vampireAwakeningPanel.tsx index 66f843c..4080cac 100644 --- a/apps/web/src/components/game/vampireAwakeningPanel.tsx +++ b/apps/web/src/components/game/vampireAwakeningPanel.tsx @@ -189,6 +189,7 @@ const VampireAwakeningPanel = (): JSX.Element => { formatInteger, awaken, buyAwakeningUpgrade, + vampirePreview, } = useGame(); const [ isPending, setIsPending ] = useState(false); @@ -208,7 +209,7 @@ const VampireAwakeningPanel = (): JSX.Element => { ); } - const { vampire } = state; + const vampire = state.vampire ?? vampirePreview; if (vampire === undefined) { return ( diff --git a/apps/web/src/components/game/vampireBossPanel.tsx b/apps/web/src/components/game/vampireBossPanel.tsx index 66dd52a..6a0cc0a 100644 --- a/apps/web/src/components/game/vampireBossPanel.tsx +++ b/apps/web/src/components/game/vampireBossPanel.tsx @@ -306,6 +306,7 @@ const VampireBossPanel = (): JSX.Element => { dismissVampireBattle, formatNumber, formatInteger, + vampirePreview, } = useGame(); const [ challengingBossId, setChallengingBossId ] = useState( @@ -323,7 +324,7 @@ const VampireBossPanel = (): JSX.Element => { ); } - const { vampire } = state; + const vampire = state.vampire ?? vampirePreview; if (vampire === undefined) { return ( diff --git a/apps/web/src/components/game/vampireCraftingPanel.tsx b/apps/web/src/components/game/vampireCraftingPanel.tsx index 34397f7..146de8b 100644 --- a/apps/web/src/components/game/vampireCraftingPanel.tsx +++ b/apps/web/src/components/game/vampireCraftingPanel.tsx @@ -24,7 +24,7 @@ const bonusLabel: Record = { * @returns The JSX element. */ const VampireCraftingPanel = (): JSX.Element => { - const { state, craftVampireRecipe, formatNumber } = useGame(); + const { state, craftVampireRecipe, formatNumber, vampirePreview } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState(() => { return ( 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) { return ( diff --git a/apps/web/src/components/game/vampireEquipmentPanel.tsx b/apps/web/src/components/game/vampireEquipmentPanel.tsx index e24e47d..5c4ff37 100644 --- a/apps/web/src/components/game/vampireEquipmentPanel.tsx +++ b/apps/web/src/components/game/vampireEquipmentPanel.tsx @@ -192,7 +192,7 @@ type TabFilter = "all" | VampireEquipmentType; * @returns The JSX element. */ const VampireEquipmentPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, vampirePreview } = useGame(); const [ activeTab, setActiveTab ] = useState("all"); 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) { return ( diff --git a/apps/web/src/components/game/vampireExplorationPanel.tsx b/apps/web/src/components/game/vampireExplorationPanel.tsx index 4191b42..907da34 100644 --- a/apps/web/src/components/game/vampireExplorationPanel.tsx +++ b/apps/web/src/components/game/vampireExplorationPanel.tsx @@ -85,6 +85,7 @@ const VampireExplorationPanel = (): JSX.Element => { startVampireExploration, collectVampireExploration, formatNumber, + vampirePreview, } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState(() => { return ( @@ -160,7 +161,7 @@ const VampireExplorationPanel = (): JSX.Element => { ); } - const { vampire } = state; + const vampire = state.vampire ?? vampirePreview; if (vampire === undefined) { return ( diff --git a/apps/web/src/components/game/vampireQuestsPanel.tsx b/apps/web/src/components/game/vampireQuestsPanel.tsx index 4d8b29b..7786797 100644 --- a/apps/web/src/components/game/vampireQuestsPanel.tsx +++ b/apps/web/src/components/game/vampireQuestsPanel.tsx @@ -6,6 +6,7 @@ */ /* 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 complexity -- Expansion preview fallback adds necessary branching */ import { useState, type JSX } from "react"; import { useGame } from "../../context/gameContext.js"; import type { @@ -140,7 +141,7 @@ const VampireQuestCard = ({ * @returns The JSX element. */ const VampireQuestsPanel = (): JSX.Element => { - const { state, toggleVampireAutoQuest } = useGame(); + const { state, toggleVampireAutoQuest, vampirePreview } = useGame(); const [ activeZoneId, setActiveZoneId ] = useState(() => { return sessionStorage.getItem("elysium_vampire_quest_zone") ?? "vampire_haunted_catacombs"; @@ -154,7 +155,7 @@ const VampireQuestsPanel = (): JSX.Element => { ); } - const vampireState = state.vampire; + const vampireState = state.vampire ?? vampirePreview; if (vampireState === undefined) { return (
diff --git a/apps/web/src/components/game/vampireSiringPanel.tsx b/apps/web/src/components/game/vampireSiringPanel.tsx index ee39fef..ecd2b32 100644 --- a/apps/web/src/components/game/vampireSiringPanel.tsx +++ b/apps/web/src/components/game/vampireSiringPanel.tsx @@ -305,6 +305,7 @@ const VampireSiringPanel = (): JSX.Element => { formatNumber, sire, buySiringUpgrade, + vampirePreview, } = useGame(); const [ isPending, setIsPending ] = useState(false); @@ -324,7 +325,7 @@ const VampireSiringPanel = (): JSX.Element => { ); } - const { vampire } = state; + const vampire = state.vampire ?? vampirePreview; if (vampire === undefined) { return ( diff --git a/apps/web/src/components/game/vampireThrallsPanel.tsx b/apps/web/src/components/game/vampireThrallsPanel.tsx index 3d75215..97474a6 100644 --- a/apps/web/src/components/game/vampireThrallsPanel.tsx +++ b/apps/web/src/components/game/vampireThrallsPanel.tsx @@ -198,7 +198,9 @@ const ThrallCard = ({ * @returns The JSX element. */ const VampireThrallsPanel = (): JSX.Element => { - const { state, formatNumber, toggleVampireAutoThrall } = useGame(); + const { + state, formatNumber, toggleVampireAutoThrall, vampirePreview, + } = useGame(); const [ selectedBatch, setSelectedBatch ] = useState(() => { 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) { return (
diff --git a/apps/web/src/components/game/vampireUpgradesPanel.tsx b/apps/web/src/components/game/vampireUpgradesPanel.tsx index d6255db..9d54be1 100644 --- a/apps/web/src/components/game/vampireUpgradesPanel.tsx +++ b/apps/web/src/components/game/vampireUpgradesPanel.tsx @@ -167,7 +167,7 @@ const VampireUpgradeCard = ({ * @returns The JSX element. */ const VampireUpgradesPanel = (): JSX.Element => { - const { state, formatNumber } = useGame(); + const { state, formatNumber, vampirePreview } = useGame(); if (state === null) { return ( @@ -177,7 +177,8 @@ const VampireUpgradesPanel = (): JSX.Element => { ); } - const { resources, vampire } = state; + const { resources } = state; + const vampire = state.vampire ?? vampirePreview; if (vampire === undefined) { return ( diff --git a/apps/web/src/components/game/vampireZonesPanel.tsx b/apps/web/src/components/game/vampireZonesPanel.tsx index e42826f..cc0450e 100644 --- a/apps/web/src/components/game/vampireZonesPanel.tsx +++ b/apps/web/src/components/game/vampireZonesPanel.tsx @@ -81,7 +81,7 @@ const VampireZoneCard = ({ * @returns The JSX element. */ const VampireZonesPanel = (): JSX.Element => { - const { state } = useGame(); + const { state, vampirePreview } = useGame(); if (state === null) { return ( @@ -91,7 +91,7 @@ const VampireZonesPanel = (): JSX.Element => { ); } - const { vampire } = state; + const vampire = state.vampire ?? vampirePreview; if (vampire === undefined) { return ( diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index e2be73a..ac79ee6 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -15,6 +15,7 @@ import { STORY_CHAPTERS, type Achievement, type ApotheosisResponse, + type AwakeningResponse, type BossChallengeResponse, type ConsecrationResponse, type EnlightenmentResponse, @@ -22,14 +23,15 @@ import { type GameState, type GoddessBossChallengeResponse, type GoddessExploreCollectResponse, - type AwakeningResponse, - type SiringResponse, - type VampireBossChallengeResponse, - type VampireExploreCollectResponse, + type GoddessState, type LoginBonusResult, type NumberFormat, type Quest, + type SiringResponse, type TranscendenceResponse, + type VampireBossChallengeResponse, + type VampireExploreCollectResponse, + type VampireState, computeUnlockedCompanionIds, isStoryChapterUnlocked, } from "@elysium/types"; @@ -865,6 +867,18 @@ interface GameContextValue { * Toggle the vampire auto-thrall setting on/off. */ 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 { @@ -953,6 +967,12 @@ export const GameProvider = ({ const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); const [ inGuild, setInGuild ] = useState(false); + const [ goddessPreview, setGoddessPreview ] = useState< + GoddessState | undefined + >(undefined); + const [ vampirePreview, setVampirePreview ] = useState< + VampireState | undefined + >(undefined); const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< Array >([]); @@ -991,6 +1011,8 @@ export const GameProvider = ({ setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); setInGuild(data.inGuild); + setGoddessPreview(data.expansionPreview.goddess); + setVampirePreview(data.expansionPreview.vampire); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`). @@ -1051,6 +1073,8 @@ export const GameProvider = ({ setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); setInGuild(data.inGuild); + setGoddessPreview(data.expansionPreview.goddess); + setVampirePreview(data.expansionPreview.vampire); } catch (error_: unknown) { setError( error_ instanceof Error @@ -3577,6 +3601,8 @@ export const GameProvider = ({ setOfflineGold(0); setOfflineEssence(0); setLoginBonus(null); + setGoddessPreview(data.expansionPreview.goddess); + setVampirePreview(data.expansionPreview.vampire); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); @@ -3697,6 +3723,8 @@ export const GameProvider = ({ setOfflineGold(0); setOfflineEssence(0); setLoginBonus(null); + setGoddessPreview(data.expansionPreview.goddess); + setVampirePreview(data.expansionPreview.vampire); if (data.signature !== undefined) { signatureReference.current = data.signature; localStorage.setItem("elysium_save_signature", data.signature); @@ -3796,6 +3824,7 @@ export const GameProvider = ({ formatInteger, formatNumber, goddessBattleResult, + goddessPreview, handleClick, inGuild, isLoading, @@ -3841,6 +3870,7 @@ export const GameProvider = ({ unlockedCodexEntryIds, unlockedStoryChapterIds, vampireBattleResult, + vampirePreview, }; }, [ apotheosis, @@ -3907,6 +3937,7 @@ export const GameProvider = ({ formatInteger, formatNumber, goddessBattleResult, + goddessPreview, handleClick, inGuild, isLoading, @@ -3951,6 +3982,7 @@ export const GameProvider = ({ unlockedCodexEntryIds, unlockedStoryChapterIds, vampireBattleResult, + vampirePreview, ]); return ( diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c87e8b5..b9a7d72 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -5415,3 +5415,29 @@ body.vampire-mode { justify-content: center; 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; +} diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index b4d8b30..703afb0 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -11,9 +11,11 @@ import type { EquipmentType, } from "./equipment.js"; import type { GameState } from "./gameState.js"; +import type { GoddessState } from "./goddessState.js"; import type { Player } from "./player.js"; import type { ProfileSettings } from "./profileSettings.js"; import type { CompletedChapter } from "./story.js"; +import type { VampireState } from "./vampireState.js"; interface AuthResponse { token: string; @@ -114,6 +116,14 @@ interface LoadResponse { * The current expected schema version from the server. */ currentSchemaVersion: number; + + /** + * Initial expansion states for preview display — never saved to game state. + */ + expansionPreview: { + goddess: GoddessState; + vampire: VampireState; + }; } interface BossChallengeRequest {