feat: goddess expansion chunks 6–9 — UI panels, tick engine, CSS theme, about page

- Add 11 goddess panels (zones, bosses, quests, disciples, equipment,
  upgrades, consecration, enlightenment, crafting, exploration, achievements)
- Wire all panels into gameLayout via mode/tab routing
- Add goddess passive income, disciple tick, quest timers, zone/quest
  unlock logic, and achievement checking to the tick engine
- Add goddess CSS variables, .goddess-mode overrides, 300ms fade
  transition, and full panel stylesheet coverage
- Add 13 Goddess expansion entries to the How to Play guide
- Add web-side data files for crafting recipes, exploration areas, materials
This commit is contained in:
2026-04-13 18:38:27 -07:00
committed by Naomi Carrigan
parent 96d6759661
commit 91c9f52daf
21 changed files with 7093 additions and 108 deletions
+586 -5
View File
@@ -16,8 +16,12 @@ import {
type Achievement,
type ApotheosisResponse,
type BossChallengeResponse,
type ConsecrationResponse,
type EnlightenmentResponse,
type ExploreCollectResponse,
type GameState,
type GoddessBossChallengeResponse,
type GoddessExploreCollectResponse,
type LoginBonusResult,
type NumberFormat,
type Quest,
@@ -38,12 +42,20 @@ import {
} from "react";
import {
achieveApotheosis as achieveApotheosisApi,
buyConsecrationUpgrade as buyConsecrationUpgradeApi,
buyEchoUpgrade as buyEchoUpgradeApi,
buyEnlightenmentUpgrade as buyEnlightenmentUpgradeApi,
buyGoddessUpgrade as buyGoddessUpgradeApi,
buyPrestigeUpgrade as buyPrestigeUpgradeApi,
challengeBoss as challengeBossApi,
challengeGoddessBoss as challengeGoddessBossApi,
collectExploration as collectExplorationApi,
collectGoddessExploration as collectGoddessExplorationApi,
consecrate as consecrateApi,
craftGoddessRecipe as craftGoddessRecipeApi,
craftRecipe as craftRecipeApi,
debugHardReset as debugHardResetApi,
enlighten as enlightenApi,
forceUnlocks as forceUnlocksApi,
syncNewContent as syncNewContentApi,
loadGame,
@@ -51,6 +63,7 @@ import {
resetProgress as resetProgressApi,
saveGame,
startExploration as startExplorationApi,
startGoddessExploration as startGoddessExplorationApi,
transcend as transcendApi,
} from "../api/client.js";
import { CODEX_ENTRIES } from "../data/codex.js";
@@ -663,6 +676,98 @@ interface GameContextValue {
* error). Cleared automatically when a new challenge is initiated.
*/
bossError: string | null;
/**
* Challenge a goddess boss — runs full server-side divine combat simulation.
*/
challengeGoddessBoss: (bossId: string)=> Promise<void>;
/**
* Goddess battle result to display (null when no battle pending).
*/
goddessBattleResult: GoddessBossChallengeResponse | null;
/**
* Dismiss the goddess battle result modal.
*/
dismissGoddessBattle: ()=> void;
/**
* Perform a consecration — goddess prestige, earning divinity.
*/
consecrate: ()=> Promise<ConsecrationResponse>;
/**
* Whether the consecration milestone toast is currently showing.
*/
showConsecrationToast: boolean;
/**
* Dismiss the consecration milestone toast.
*/
dismissConsecrationToast: ()=> void;
/**
* Purchase a consecration upgrade from the divinity shop.
*/
buyConsecrationUpgrade: (upgradeId: string)=> Promise<void>;
/**
* Perform an enlightenment — goddess transcendence, earning stardust.
*/
enlighten: ()=> Promise<EnlightenmentResponse>;
/**
* Whether the enlightenment milestone toast is currently showing.
*/
showEnlightenmentToast: boolean;
/**
* Dismiss the enlightenment milestone toast.
*/
dismissEnlightenmentToast: ()=> void;
/**
* Purchase an enlightenment upgrade from the stardust shop.
*/
buyEnlightenmentUpgrade: (upgradeId: string)=> Promise<void>;
/**
* Purchase a goddess upgrade using prayers/divinity/stardust.
*/
buyGoddessUpgrade: (upgradeId: string)=> Promise<void>;
/**
* Buy one or more disciples (client-side state mutation).
*/
buyGoddessDisciple: (discipleId: string, quantity: number)=> void;
/**
* Purchase a goddess equipment item (client-side state mutation).
*/
buyGoddessEquipment: (equipmentId: string)=> void;
/**
* Equip a owned goddess equipment item (auto-unequips same slot).
*/
equipGoddessItem: (equipmentId: string)=> void;
/**
* Craft a goddess recipe using sacred materials.
*/
craftGoddessRecipe: (recipeId: string)=> Promise<void>;
/**
* Start a goddess exploration in the given area.
*/
startGoddessExploration: (areaId: string)=> Promise<void>;
/**
* Collect results of a completed goddess exploration.
*/
collectGoddessExploration: (
areaId: string,
)=> Promise<GoddessExploreCollectResponse>;
}
export interface BattleResult {
@@ -713,6 +818,10 @@ export const GameProvider = ({
} | null>(null);
const [ autoBossError, setAutoBossError ] = useState<string | null>(null);
const [ bossError, setBossError ] = useState<string | null>(null);
const [ goddessBattleResult, setGoddessBattleResult ]
= useState<GoddessBossChallengeResponse | null>(null);
const [ showConsecrationToast, setShowConsecrationToast ] = useState(false);
const [ showEnlightenmentToast, setShowEnlightenmentToast ] = useState(false);
const syncErrorTimerReference = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
@@ -1935,6 +2044,442 @@ export const GameProvider = ({
}
}, []);
const challengeGoddessBoss = useCallback(async(bossId: string) => {
setGoddessBattleResult(null);
try {
const result = await challengeGoddessBossApi({ bossId });
if (result.signature !== undefined) {
signatureReference.current = result.signature;
localStorage.setItem("elysium_save_signature", result.signature);
}
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
return {
...previous,
goddess: {
...previous.goddess,
bosses: previous.goddess.bosses.map((b) => {
return b.id === bossId
? { ...b, currentHp: result.bossNewHp, status: result.won
? "defeated" as const
: "available" as const }
: b;
}),
resources: {
...previous.resources,
prayers: (previous.resources.prayers ?? 0)
+ (result.rewards?.prayers ?? 0),
},
},
};
});
setGoddessBattleResult(result);
} catch (error_: unknown) {
logError("challenge_goddess_boss", error_);
}
}, []);
const dismissGoddessBattle = useCallback(() => {
setGoddessBattleResult(null);
}, []);
const consecrate = useCallback(async() => {
try {
const result = await consecrateApi({});
setShowConsecrationToast(true);
await reload();
return result;
} catch (error_: unknown) {
logError("consecrate", error_);
throw error_;
}
}, [ reload ]);
const dismissConsecrationToast = useCallback(() => {
setShowConsecrationToast(false);
}, []);
const buyConsecrationUpgrade = useCallback(async(upgradeId: string) => {
try {
const result = await buyConsecrationUpgradeApi({ upgradeId });
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
return {
...previous,
goddess: {
...previous.goddess,
consecration: {
...previous.goddess.consecration,
divinity: result.divinityRemaining,
divinityCombatMultiplier: result.divinityCombatMultiplier,
divinityDisciplesMultiplier: result.divinityDisciplesMultiplier,
divinityPrayersMultiplier: result.divinityPrayersMultiplier,
purchasedUpgradeIds: result.purchasedUpgradeIds,
},
},
};
});
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} catch (error_: unknown) {
logError("buy_consecration_upgrade", error_);
}
}, []);
const enlighten = useCallback(async() => {
try {
const result = await enlightenApi({});
setShowEnlightenmentToast(true);
await reload();
return result;
} catch (error_: unknown) {
logError("enlighten", error_);
throw error_;
}
}, [ reload ]);
const dismissEnlightenmentToast = useCallback(() => {
setShowEnlightenmentToast(false);
}, []);
const buyEnlightenmentUpgrade = useCallback(async(upgradeId: string) => {
try {
const result = await buyEnlightenmentUpgradeApi({ upgradeId });
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
return {
...previous,
goddess: {
...previous.goddess,
enlightenment: {
...previous.goddess.enlightenment,
purchasedUpgradeIds:
result.purchasedUpgradeIds,
stardust:
result.stardustRemaining,
stardustCombatMultiplier:
result.stardustCombatMultiplier,
stardustConsecrationDivinityMultiplier:
result.stardustConsecrationDivinityMultiplier,
stardustConsecrationThresholdMultiplier:
result.stardustConsecrationThresholdMultiplier,
stardustMetaMultiplier:
result.stardustMetaMultiplier,
stardustPrayersMultiplier:
result.stardustPrayersMultiplier,
},
},
};
});
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} catch (error_: unknown) {
logError("buy_enlightenment_upgrade", error_);
}
}, []);
const buyGoddessUpgrade = useCallback(async(upgradeId: string) => {
try {
const result = await buyGoddessUpgradeApi({ upgradeId });
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
return {
...previous,
goddess: {
...previous.goddess,
upgrades: previous.goddess.upgrades.map((u) => {
return u.id === upgradeId
? { ...u, purchased: true }
: u;
}),
},
resources: {
...previous.resources,
divinity: result.divinityRemaining,
prayers: result.prayersRemaining,
stardust: result.stardustRemaining,
},
};
});
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
} catch (error_: unknown) {
logError("buy_goddess_upgrade", error_);
}
}, []);
const buyGoddessDisciple = useCallback(
(discipleId: string, quantity: number) => {
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
const disciple = previous.goddess.disciples.find((d) => {
return d.id === discipleId;
});
if (disciple === undefined) {
return previous;
}
const geometric = disciple.baseCost * (1 - Math.pow(1.15, quantity));
const normalised = geometric / (1 - 1.15);
const totalCost = normalised * Math.pow(1.15, disciple.count);
const currentPrayers = previous.resources.prayers ?? 0;
if (currentPrayers < totalCost) {
return previous;
}
return {
...previous,
goddess: {
...previous.goddess,
disciples: previous.goddess.disciples.map((d) => {
return d.id === discipleId
? { ...d, count: d.count + quantity }
: d;
}),
},
resources: {
...previous.resources,
prayers: currentPrayers - totalCost,
},
};
});
},
[],
);
const buyGoddessEquipment = useCallback((equipmentId: string) => {
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
const item = previous.goddess.equipment.find((equip) => {
return equip.id === equipmentId;
});
if (item?.owned === true) {
return previous;
}
const prayers = previous.resources.prayers ?? 0;
const divinity = previous.resources.divinity ?? 0;
if (
prayers < (item?.cost?.prayers ?? 0)
|| divinity < (item?.cost?.divinity ?? 0)
) {
return previous;
}
const slotAlreadyEquipped = previous.goddess.equipment.find((equip) => {
return equip.equipped && equip.type === item?.type;
});
return {
...previous,
goddess: {
...previous.goddess,
equipment: previous.goddess.equipment.map((equip) => {
if (equip.id === equipmentId) {
return {
...equip,
equipped: slotAlreadyEquipped === undefined,
owned: true,
};
}
if (equip.id === slotAlreadyEquipped?.id) {
return { ...equip, equipped: false };
}
return equip;
}),
},
resources: {
...previous.resources,
divinity: divinity - (item?.cost?.divinity ?? 0),
prayers: prayers - (item?.cost?.prayers ?? 0),
},
};
});
}, []);
const equipGoddessItem = useCallback((equipmentId: string) => {
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
const item = previous.goddess.equipment.find((equip) => {
return equip.id === equipmentId;
});
if (item?.owned !== true) {
return previous;
}
const slotAlreadyEquipped = previous.goddess.equipment.find((equip) => {
return (
equip.equipped && equip.type === item.type && equip.id !== equipmentId
);
});
return {
...previous,
goddess: {
...previous.goddess,
equipment: previous.goddess.equipment.map((equip) => {
if (equip.id === equipmentId) {
return { ...equip, equipped: true };
}
if (equip.id === slotAlreadyEquipped?.id) {
return { ...equip, equipped: false };
}
return equip;
}),
},
};
});
}, []);
const craftGoddessRecipe = useCallback(async(recipeId: string) => {
try {
const result = await craftGoddessRecipeApi({ recipeId });
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
let materials = [ ...previous.goddess.exploration.materials ];
for (const cost of result.materials) {
materials = materials.map((m) => {
return m.materialId === cost.materialId
? { ...m, quantity: m.quantity - cost.quantity }
: m;
});
}
return {
...previous,
goddess: {
...previous.goddess,
exploration: {
...previous.goddess.exploration,
craftedCombatMultiplier: result.craftedCombatMultiplier,
craftedDivinityMultiplier: result.craftedDivinityMultiplier,
craftedPrayersMultiplier: result.craftedPrayersMultiplier,
craftedRecipeIds: [
...previous.goddess.exploration.craftedRecipeIds,
result.recipeId,
],
materials: materials,
},
},
};
});
} catch (error_: unknown) {
logError("craft_goddess_recipe", error_);
}
}, []);
const startGoddessExploration = useCallback(async(areaId: string) => {
const response = await startGoddessExplorationApi({ areaId });
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
return {
...previous,
goddess: {
...previous.goddess,
exploration: {
...previous.goddess.exploration,
areas: previous.goddess.exploration.areas.map((a) => {
return a.id === areaId
? {
...a,
endsAt: response.endsAt,
status: "in_progress" as const,
}
: a;
}),
},
},
};
});
}, []);
const collectGoddessExploration = useCallback(
async(areaId: string): Promise<GoddessExploreCollectResponse> => {
isSyncingReference.current = true;
const result = await collectGoddessExplorationApi({ areaId });
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
lastSaveReference.current = Date.now();
isSyncingReference.current = false;
setState((previous) => {
if (previous?.goddess === undefined) {
return previous;
}
let materials = [ ...previous.goddess.exploration.materials ];
for (const drop of result.materialsFound) {
const existing = materials.find((m) => {
return m.materialId === drop.materialId;
});
if (existing === undefined) {
materials = [
...materials,
{ materialId: drop.materialId, quantity: drop.quantity },
];
} else {
materials = materials.map((m) => {
return m.materialId === drop.materialId
? { ...m, quantity: m.quantity + drop.quantity }
: m;
});
}
}
const materialGained = result.event?.materialGained;
if (materialGained !== null && materialGained !== undefined) {
const { materialId, quantity } = materialGained;
const existing = materials.find((m) => {
return m.materialId === materialId;
});
if (existing === undefined) {
materials = [ ...materials, { materialId, quantity } ];
} else {
materials = materials.map((m) => {
return m.materialId === materialId
? { ...m, quantity: m.quantity + quantity }
: m;
});
}
}
return {
...previous,
goddess: {
...previous.goddess,
exploration: {
...previous.goddess.exploration,
areas: previous.goddess.exploration.areas.map((a) => {
return a.id === areaId
? { ...a, completedOnce: true, status: "available" as const }
: a;
}),
materials: materials,
},
},
resources: {
...previous.resources,
prayers: Math.max(
0,
(previous.resources.prayers ?? 0)
+ (result.event?.prayersChange ?? 0),
),
},
};
});
return result;
},
[],
);
const startExploration = useCallback(async(areaId: string) => {
const response = await startExplorationApi({ areaId });
setState((previous) => {
@@ -2492,14 +3037,23 @@ export const GameProvider = ({
battleResult,
bossError,
buyAdventurer,
buyConsecrationUpgrade,
buyEchoUpgrade,
buyEnlightenmentUpgrade,
buyEquipment,
buyGoddessDisciple,
buyGoddessEquipment,
buyGoddessUpgrade,
buyPrestigeUpgrade,
buyUpgrade,
challengeBoss,
challengeGoddessBoss,
collectExploration,
collectGoddessExploration,
completeChapter,
completedQuestToasts,
consecrate,
craftGoddessRecipe,
craftRecipe,
currentSchemaVersion,
debugHardReset,
@@ -2508,7 +3062,10 @@ export const GameProvider = ({
dismissBattle,
dismissCodexEntry,
dismissCompletedQuest,
dismissConsecrationToast,
dismissEnlightenmentToast,
dismissFailedQuest,
dismissGoddessBattle,
dismissLoginBonus,
dismissOfflineGold,
dismissPrestigeToast,
@@ -2516,6 +3073,8 @@ export const GameProvider = ({
dismissTranscendenceToast,
enableNotifications,
enableSounds,
enlighten,
equipGoddessItem,
equipItem,
error,
failedQuestToasts,
@@ -2524,6 +3083,7 @@ export const GameProvider = ({
forceUnlocks,
formatInteger,
formatNumber,
goddessBattleResult,
handleClick,
inGuild,
isLoading,
@@ -2544,9 +3104,12 @@ export const GameProvider = ({
setEnableSounds,
setNumberFormat,
showApotheosisToast,
showConsecrationToast,
showEnlightenmentToast,
showPrestigeToast,
showTranscendenceToast,
startExploration,
startGoddessExploration,
startQuest,
state,
syncError,
@@ -2568,18 +3131,24 @@ export const GameProvider = ({
autoBossLastResult,
battleResult,
bossError,
completedQuestToasts,
failedQuestToasts,
formatInteger,
formatNumber,
buyAdventurer,
buyConsecrationUpgrade,
buyEchoUpgrade,
buyEnlightenmentUpgrade,
buyEquipment,
buyGoddessDisciple,
buyGoddessEquipment,
buyGoddessUpgrade,
buyPrestigeUpgrade,
buyUpgrade,
challengeBoss,
challengeGoddessBoss,
collectExploration,
collectGoddessExploration,
completeChapter,
completedQuestToasts,
consecrate,
craftGoddessRecipe,
craftRecipe,
currentSchemaVersion,
debugHardReset,
@@ -2588,7 +3157,10 @@ export const GameProvider = ({
dismissBattle,
dismissCodexEntry,
dismissCompletedQuest,
dismissConsecrationToast,
dismissEnlightenmentToast,
dismissFailedQuest,
dismissGoddessBattle,
dismissLoginBonus,
dismissOfflineGold,
dismissPrestigeToast,
@@ -2596,13 +3168,19 @@ export const GameProvider = ({
dismissTranscendenceToast,
enableNotifications,
enableSounds,
enlighten,
equipGoddessItem,
equipItem,
error,
failedQuestToasts,
flushBossLoreToasts,
forceSync,
inGuild,
forceUnlocks,
formatInteger,
formatNumber,
goddessBattleResult,
handleClick,
inGuild,
isLoading,
isSyncing,
lastSavedAt,
@@ -2620,9 +3198,12 @@ export const GameProvider = ({
setEnableSounds,
setNumberFormat,
showApotheosisToast,
showConsecrationToast,
showEnlightenmentToast,
showPrestigeToast,
showTranscendenceToast,
startExploration,
startGoddessExploration,
startQuest,
state,
syncError,