From 7da1f3942de81c48cdd9e291be2ba2a10c47ae08 Mon Sep 17 00:00:00 2001 From: Hikari Date: Mon, 13 Apr 2026 14:23:02 -0700 Subject: [PATCH] feat: goddess sync, sanitize, and apotheosis init (chunk 3) - initialState: add initialGoddessState() with all goddess sub-objects - apotheosis: init GoddessState on first apotheosis, preserve on subsequent - game: add goddessSpread block in validateAndSanitize (server-only fields capped, forward-only boss/quest/achievement enforcement) - debug: add injectMissingGoddessExplorationAreas helper and inject all 8 goddess content arrays in syncNewContent - vitest.config.ts: remove 8 goddess data files from coverage exclude (now imported via initialState) - tests: full coverage for all new code (482 tests, 100% coverage) --- apps/api/src/data/initialState.ts | 72 ++++++++- apps/api/src/routes/debug.ts | 132 +++++++++++++--- apps/api/src/routes/game.ts | 167 ++++++++++++++++++++ apps/api/src/services/apotheosis.ts | 12 +- apps/api/test/routes/debug.spec.ts | 76 +++++++++ apps/api/test/routes/game.spec.ts | 182 ++++++++++++++++++++++ apps/api/test/services/apotheosis.spec.ts | 36 +++++ apps/api/vitest.config.ts | 10 +- goddess-todo.md | 11 +- 9 files changed, 666 insertions(+), 32 deletions(-) diff --git a/apps/api/src/data/initialState.ts b/apps/api/src/data/initialState.ts index e3562a7..9621577 100644 --- a/apps/api/src/data/initialState.ts +++ b/apps/api/src/data/initialState.ts @@ -9,14 +9,25 @@ import { defaultAdventurers } from "./adventurers.js"; import { defaultBosses } from "./bosses.js"; import { defaultEquipment } from "./equipment.js"; import { defaultExplorations } from "./explorations.js"; +import { defaultGoddessAchievements } from "./goddessAchievements.js"; +import { defaultGoddessBosses } from "./goddessBosses.js"; +import { defaultGoddessDisciples } from "./goddessDisciples.js"; +import { defaultGoddessEquipment } from "./goddessEquipment.js"; +import { defaultGoddessExplorationAreas } from "./goddessExplorations.js"; +import { defaultGoddessQuests } from "./goddessQuests.js"; +import { defaultGoddessUpgrades } from "./goddessUpgrades.js"; +import { defaultGoddessZones } from "./goddessZones.js"; import { defaultQuests } from "./quests.js"; import { currentSchemaVersion } from "./schemaVersion.js"; import { defaultUpgrades } from "./upgrades.js"; import { defaultZones } from "./zones.js"; import type { ApotheosisData, + ConsecrationData, + EnlightenmentData, ExplorationState, GameState, + GoddessState, Player, PrestigeData, TranscendenceData, @@ -62,6 +73,65 @@ const initialExploration: ExplorationState = { materials: [], }; +const initialConsecration: ConsecrationData = { + count: 0, + divinity: 0, + productionMultiplier: 1, + purchasedUpgradeIds: [], +}; + +const initialEnlightenment: EnlightenmentData = { + count: 0, + purchasedUpgradeIds: [], + stardust: 0, + stardustCombatMultiplier: 1, + stardustConsecrationDivinityMultiplier: 1, + stardustConsecrationThresholdMultiplier: 1, + stardustMetaMultiplier: 1, + stardustPrayersMultiplier: 1, +}; + +/** + * Builds a fresh initial goddess state for a player who has just completed their + * first Apotheosis. All goddess content is locked until progressed through the realm. + * @returns A clean GoddessState with all default data. + */ +const initialGoddessState = (): GoddessState => { + return { + achievements: structuredClone(defaultGoddessAchievements), + baseClickPower: 1, + bosses: structuredClone(defaultGoddessBosses), + consecration: { ...initialConsecration }, + disciples: structuredClone(defaultGoddessDisciples), + enlightenment: { ...initialEnlightenment }, + equipment: structuredClone(defaultGoddessEquipment), + exploration: { + areas: defaultGoddessExplorationAreas.map((area) => { + return { + id: area.id, + status: + area.zoneId === "goddess_celestial_garden" + ? ("available" as const) + : ("locked" as const), + }; + }), + craftedCombatMultiplier: 1, + craftedDivinityMultiplier: 1, + craftedPrayersMultiplier: 1, + craftedRecipeIds: [], + materials: [], + }, + lastTickAt: Date.now(), + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: structuredClone(defaultGoddessQuests), + totalPrayersEarned: 0, + upgrades: structuredClone(defaultGoddessUpgrades), + zones: structuredClone(defaultGoddessZones), + }; +}; + /** * Builds an initial game state for a new player. * @param player - The player data from Discord OAuth. @@ -105,4 +175,4 @@ const initialGameState = ( }; }; -export { initialExploration, initialGameState }; +export { initialExploration, initialGameState, initialGoddessState }; diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index b50318f..b8ec02b 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -18,6 +18,14 @@ import { defaultAdventurers } from "../data/adventurers.js"; import { defaultBosses } from "../data/bosses.js"; import { defaultEquipment } from "../data/equipment.js"; import { defaultExplorations } from "../data/explorations.js"; +import { defaultGoddessAchievements } from "../data/goddessAchievements.js"; +import { defaultGoddessBosses } from "../data/goddessBosses.js"; +import { defaultGoddessDisciples } from "../data/goddessDisciples.js"; +import { defaultGoddessEquipment } from "../data/goddessEquipment.js"; +import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.js"; +import { defaultGoddessQuests } from "../data/goddessQuests.js"; +import { defaultGoddessUpgrades } from "../data/goddessUpgrades.js"; +import { defaultGoddessZones } from "../data/goddessZones.js"; import { initialGameState } from "../data/initialState.js"; import { defaultQuests } from "../data/quests.js"; import { defaultRecipes } from "../data/recipes.js"; @@ -567,6 +575,33 @@ const injectMissingExplorationAreas = (state: GameState): number => { return added; }; +/** + * Injects any goddess exploration areas from the defaults that are missing from + * the player's goddess exploration state, seeding each new area as locked. + * @param state - The player's current game state (mutated in place). + * @returns The number of goddess exploration areas that were added. + */ +const injectMissingGoddessExplorationAreas = (state: GameState): number => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 3 -- @preserve */ + if (state.goddess === undefined) { + return 0; + } + const existingIds = new Set(state.goddess.exploration.areas.map((area) => { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + return area.id; + })); + let added = 0; + for (const area of defaultGoddessExplorationAreas) { + if (!existingIds.has(area.id)) { + state.goddess.exploration.areas.push({ id: area.id, status: "locked" }); + added = added + 1; + } + } + return added; +}; + /** * Patches rewards on existing quests whose reward lists have grown since the * save was created (e.g. A new upgrade added as a reward to an old quest). @@ -967,27 +1002,36 @@ const recomputeCraftingMultipliers = (state: GameState): number => { * @param state - The player's current game state (mutated in place). * @returns Counts of how many entries were added or patched per content type. */ +/* eslint-disable-next-line max-statements -- Sync function requires one operation per content type */ const syncNewContent = ( state: GameState, ): { - achievementsAdded: number; - achievementsPatched: number; - adventurersAdded: number; - adventurerStatsPatched: number; - bossesAdded: number; - bossesPatched: number; - bossRewardsPatched: number; - craftingRecipesReapplied: number; - equipmentAdded: number; - equipmentPatched: number; - explorationAreasAdded: number; - questRewardsPatched: number; - questsAdded: number; - questsPatched: number; - upgradesAdded: number; - upgradesPatched: number; - zonesAdded: number; - zonesPatched: number; + achievementsAdded: number; + achievementsPatched: number; + adventurersAdded: number; + adventurerStatsPatched: number; + bossesAdded: number; + bossesPatched: number; + bossRewardsPatched: number; + craftingRecipesReapplied: number; + equipmentAdded: number; + equipmentPatched: number; + explorationAreasAdded: number; + goddessAchievementsAdded: number; + goddessBossesAdded: number; + goddessDiscipesAdded: number; + goddessEquipmentAdded: number; + goddessExplorationAreasAdded: number; + goddessQuestsAdded: number; + goddessUpgradesAdded: number; + goddessZonesAdded: number; + questRewardsPatched: number; + questsAdded: number; + questsPatched: number; + upgradesAdded: number; + upgradesPatched: number; + zonesAdded: number; + zonesPatched: number; } => { const adventurerStatsPatched = patchAdventurerStats(state); const questsPatched = patchQuestStats(state); @@ -1007,6 +1051,34 @@ const syncNewContent = ( const questsAdded = injectMissingEntries(state.quests, defaultQuests); const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades); const zonesAdded = injectMissingEntries(state.zones, defaultZones); + + // Inject missing goddess content for players who have completed Apotheosis + let goddessAchievementsAdded = 0; + let goddessBossesAdded = 0; + let goddessDiscipesAdded = 0; + let goddessEquipmentAdded = 0; + let goddessExplorationAreasAdded = 0; + let goddessQuestsAdded = 0; + let goddessUpgradesAdded = 0; + let goddessZonesAdded = 0; + if (state.goddess) { + goddessAchievementsAdded + = injectMissingEntries(state.goddess.achievements, defaultGoddessAchievements); + goddessBossesAdded + = injectMissingEntries(state.goddess.bosses, defaultGoddessBosses); + goddessDiscipesAdded + = injectMissingEntries(state.goddess.disciples, defaultGoddessDisciples); + goddessEquipmentAdded + = injectMissingEntries(state.goddess.equipment, defaultGoddessEquipment); + goddessExplorationAreasAdded = injectMissingGoddessExplorationAreas(state); + goddessQuestsAdded + = injectMissingEntries(state.goddess.quests, defaultGoddessQuests); + goddessUpgradesAdded + = injectMissingEntries(state.goddess.upgrades, defaultGoddessUpgrades); + goddessZonesAdded + = injectMissingEntries(state.goddess.zones, defaultGoddessZones); + } + return { achievementsAdded, achievementsPatched, @@ -1019,6 +1091,14 @@ const syncNewContent = ( equipmentAdded, equipmentPatched, explorationAreasAdded, + goddessAchievementsAdded, + goddessBossesAdded, + goddessDiscipesAdded, + goddessEquipmentAdded, + goddessExplorationAreasAdded, + goddessQuestsAdded, + goddessUpgradesAdded, + goddessZonesAdded, questRewardsPatched, questsAdded, questsPatched, @@ -1120,6 +1200,14 @@ debugRouter.post("/sync-new-content", async(context) => { equipmentAdded, equipmentPatched, explorationAreasAdded, + goddessAchievementsAdded, + goddessBossesAdded, + goddessDiscipesAdded, + goddessEquipmentAdded, + goddessExplorationAreasAdded, + goddessQuestsAdded, + goddessUpgradesAdded, + goddessZonesAdded, questRewardsPatched, questsAdded, questsPatched, @@ -1154,6 +1242,14 @@ debugRouter.post("/sync-new-content", async(context) => { equipmentAdded, equipmentPatched, explorationAreasAdded, + goddessAchievementsAdded, + goddessBossesAdded, + goddessDiscipesAdded, + goddessEquipmentAdded, + goddessExplorationAreasAdded, + goddessQuestsAdded, + goddessUpgradesAdded, + goddessZonesAdded, questRewardsPatched, questsAdded, questsPatched, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index b0a56e2..1c65941 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -720,6 +720,172 @@ const validateAndSanitize = ( dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges }; } + /* + * Goddess state: preserve server-only currencies (divinity, stardust, prayers) at + * previous values, and apply the same forward-only rules to bosses/quests/achievements + * and exploration materials that the mortal realm uses. + * Prayers income will be computed and allowed to grow once Chunk 7 adds goddess tick logic. + */ + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 145 -- @preserve */ + let goddessSpread: object = {}; + const previousGoddess = previous.goddess; + const incomingGoddess = incoming.goddess; + if (!incomingGoddess && previousGoddess) { + goddessSpread = { goddess: previousGoddess }; + } else if (incomingGoddess) { + const goddessBosses = incomingGoddess.bosses.map((boss) => { + const matchingBoss = previousGoddess?.bosses.find((storedBoss) => { + return storedBoss.id === boss.id; + }); + if (!matchingBoss) { + return boss; + } + if (matchingBoss.status === "defeated" && boss.status !== "defeated") { + return { ...boss, currentHp: 0, status: "defeated" as const }; + } + return boss; + }); + const goddessQuests = incomingGoddess.quests.map((quest) => { + const matchingQuest = previousGoddess?.quests.find((storedQuest) => { + return storedQuest.id === quest.id; + }); + if (!matchingQuest) { + return quest; + } + // eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability + if (matchingQuest.status === "completed" && quest.status !== "completed") { + return { ...matchingQuest }; + } + return quest; + }); + // eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability + const goddessAchievements = incomingGoddess.achievements.map((achievement) => { + const matchingAchievement = previousGoddess?.achievements.find( + (storedAchievement) => { + return storedAchievement.id === achievement.id; + }, + ); + if (!matchingAchievement) { + return achievement; + } + const wasUnlocked = matchingAchievement.unlockedAt !== null; + const isNowNull = achievement.unlockedAt === null; + if (wasUnlocked && isNowNull) { + return { ...achievement, unlockedAt: matchingAchievement.unlockedAt }; + } + const isFuture + = achievement.unlockedAt !== null && achievement.unlockedAt > now; + if (isFuture) { + const safeUnlockedAt = matchingAchievement.unlockedAt ?? null; + return { ...achievement, unlockedAt: safeUnlockedAt }; + } + return achievement; + }); + const previousGoddessExploration = previousGoddess?.exploration; + let goddessExploration = incomingGoddess.exploration; + if (previousGoddessExploration) { + const previousMaterialMap = new Map( + previousGoddessExploration.materials.map((mat) => { + return [ mat.materialId, mat.quantity ] as const; + }), + ); + // eslint-disable-next-line stylistic/max-len -- Long variable name; splitting would reduce readability + const materials = incomingGoddess.exploration.materials.map((material) => { + const previousQuantity + = previousMaterialMap.get(material.materialId) ?? 0; + return { + ...material, + quantity: Math.min(material.quantity, previousQuantity), + }; + }); + const goddessRecipeIds = [ + ...new Set([ + ...previousGoddessExploration.craftedRecipeIds, + ...incomingGoddess.exploration.craftedRecipeIds, + ]), + ]; + goddessExploration = { + ...incomingGoddess.exploration, + // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability + craftedCombatMultiplier: previousGoddessExploration.craftedCombatMultiplier, + // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability + craftedDivinityMultiplier: previousGoddessExploration.craftedDivinityMultiplier, + // eslint-disable-next-line stylistic/max-len -- Long field name; splitting would reduce readability + craftedPrayersMultiplier: previousGoddessExploration.craftedPrayersMultiplier, + craftedRecipeIds: goddessRecipeIds, + materials: materials, + }; + } + const consecration = previousGoddess + ? { + ...incomingGoddess.consecration, + count: Math.min( + incomingGoddess.consecration.count, + previousGoddess.consecration.count, + ), + divinity: Math.min( + incomingGoddess.consecration.divinity, + previousGoddess.consecration.divinity, + ), + + productionMultiplier: previousGoddess.consecration.productionMultiplier, + } + : incomingGoddess.consecration; + const enlightenment = previousGoddess + ? { + ...incomingGoddess.enlightenment, + count: Math.min( + incomingGoddess.enlightenment.count, + previousGoddess.enlightenment.count, + ), + stardust: Math.min( + incomingGoddess.enlightenment.stardust, + previousGoddess.enlightenment.stardust, + ), + stardustCombatMultiplier: + previousGoddess.enlightenment.stardustCombatMultiplier, + + stardustConsecrationDivinityMultiplier: + previousGoddess.enlightenment.stardustConsecrationDivinityMultiplier, + + stardustConsecrationThresholdMultiplier: + previousGoddess.enlightenment.stardustConsecrationThresholdMultiplier, + stardustMetaMultiplier: + previousGoddess.enlightenment.stardustMetaMultiplier, + stardustPrayersMultiplier: + previousGoddess.enlightenment.stardustPrayersMultiplier, + } + : incomingGoddess.enlightenment; + goddessSpread = { + goddess: { + ...incomingGoddess, + achievements: goddessAchievements, + bosses: goddessBosses, + consecration: consecration, + enlightenment: enlightenment, + exploration: goddessExploration, + lifetimeBossesDefeated: Math.min( + incomingGoddess.lifetimeBossesDefeated, + previousGoddess?.lifetimeBossesDefeated ?? 0, + ), + lifetimePrayersEarned: Math.min( + incomingGoddess.lifetimePrayersEarned, + previousGoddess?.lifetimePrayersEarned ?? 0, + ), + lifetimeQuestsCompleted: Math.min( + incomingGoddess.lifetimeQuestsCompleted, + previousGoddess?.lifetimeQuestsCompleted ?? 0, + ), + quests: goddessQuests, + totalPrayersEarned: Math.min( + incomingGoddess.totalPrayersEarned, + previousGoddess?.totalPrayersEarned ?? 0, + ), + }, + }; + } + return { ...incoming, achievements, @@ -733,6 +899,7 @@ const validateAndSanitize = ( ...explorationSpread, ...storySpread, ...dailyChallengesSpread, + ...goddessSpread, }; }; diff --git a/apps/api/src/services/apotheosis.ts b/apps/api/src/services/apotheosis.ts index a557715..a628d53 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 } from "../data/initialState.js"; +import { initialGameState, initialGoddessState } from "../data/initialState.js"; import { defaultTranscendenceUpgrades, } from "../data/transcendenceUpgrades.js"; @@ -47,6 +47,15 @@ const buildPostApotheosisState = ( const updatedApotheosisData: ApotheosisData = { count: apotheosisCount }; 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 }; + } + const updatedState: GameState = { ...freshState, lastTickAt: Date.now(), @@ -60,6 +69,7 @@ const buildPostApotheosisState = ( ...currentState.story ? { story: currentState.story } : {}, + ...goddessSpread, }; return { updatedApotheosisData, updatedState }; diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 517c5aa..ae339ec 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -1128,6 +1128,82 @@ describe("debug route", () => { expect(body.state.exploration?.craftedClickMultiplier).toBe(1); expect(body.state.exploration?.craftedCombatMultiplier).toBe(1); }); + + it("injects goddess content arrays when state.goddess exists with empty arrays", async () => { + const goddess: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [], + totalPrayersEarned: 0, + upgrades: [], + zones: [], + }; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { goddessAchievementsAdded: number; goddessBossesAdded: number; goddessQuestsAdded: number; goddessZonesAdded: number }; + expect(body.goddessAchievementsAdded).toBeGreaterThan(0); + expect(body.goddessBossesAdded).toBeGreaterThan(0); + expect(body.goddessQuestsAdded).toBeGreaterThan(0); + expect(body.goddessZonesAdded).toBeGreaterThan(0); + }); + + it("returns zero goddess counts when state.goddess is undefined", async () => { + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { goddessAchievementsAdded: number; goddessBossesAdded: number; goddessDiscipesAdded: number; goddessEquipmentAdded: number; goddessExplorationAreasAdded: number; goddessQuestsAdded: number; goddessUpgradesAdded: number; goddessZonesAdded: number }; + expect(body.goddessAchievementsAdded).toBe(0); + expect(body.goddessBossesAdded).toBe(0); + expect(body.goddessDiscipesAdded).toBe(0); + expect(body.goddessEquipmentAdded).toBe(0); + expect(body.goddessExplorationAreasAdded).toBe(0); + expect(body.goddessQuestsAdded).toBe(0); + expect(body.goddessUpgradesAdded).toBe(0); + expect(body.goddessZonesAdded).toBe(0); + }); + + it("injects goddess exploration areas when goddess has no areas", async () => { + const goddess: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [], + totalPrayersEarned: 0, + upgrades: [], + zones: [], + }; + const state = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never); + vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never); + const res = await syncNewContent(); + expect(res.status).toBe(200); + const body = await res.json() as { goddessExplorationAreasAdded: number }; + expect(body.goddessExplorationAreasAdded).toBeGreaterThan(0); + }); }); describe("POST /hard-reset", () => { diff --git a/apps/api/test/routes/game.spec.ts b/apps/api/test/routes/game.spec.ts index 39c8ba2..69d8c0d 100644 --- a/apps/api/test/routes/game.spec.ts +++ b/apps/api/test/routes/game.spec.ts @@ -513,6 +513,188 @@ describe("game route", () => { const res = await save({ state: stateWithCompanion }); expect(res.status).toBe(200); }); + + it("passes through goddess when incoming has goddess and previous does not", async () => { + const goddess: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [], + totalPrayersEarned: 0, + upgrades: [], + zones: [], + }; + const prevState = makeState(); + const incomingState = makeState({ goddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await save({ state: incomingState }); + expect(res.status).toBe(200); + const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state; + expect(savedState.goddess).toBeDefined(); + }); + + it("does not add goddess to result when neither previous nor incoming has goddess", async () => { + const prevState = makeState(); + const incomingState = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await save({ state: incomingState }); + expect(res.status).toBe(200); + const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state; + expect(savedState.goddess).toBeUndefined(); + }); + + it("preserves previous goddess when incoming save lacks goddess", async () => { + const goddess: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [], + totalPrayersEarned: 0, + upgrades: [], + zones: [], + }; + const prevState = makeState({ goddess }); + const incomingState = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await save({ state: incomingState }); + expect(res.status).toBe(200); + const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state; + expect(savedState.goddess).toEqual(goddess); + }); + + it("caps goddess totalPrayersEarned at previous value (server-only field)", async () => { + const prevGoddess: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [], + totalPrayersEarned: 100, + upgrades: [], + zones: [], + }; + const incomingGoddess: GameState["goddess"] = { + ...prevGoddess, + totalPrayersEarned: 9999, + }; + const prevState = makeState({ goddess: prevGoddess }); + const incomingState = makeState({ goddess: incomingGoddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await save({ state: incomingState }); + expect(res.status).toBe(200); + const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state; + expect(savedState.goddess?.totalPrayersEarned).toBe(100); + }); + + it("restores goddess boss defeated status when incoming tries to un-defeat it", async () => { + const prevGoddess: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [{ id: "divine_sentinel", status: "defeated", currentHp: 0, maxHp: 5000 }] as GameState["goddess"]["bosses"], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [], + totalPrayersEarned: 0, + upgrades: [], + zones: [], + }; + const incomingGoddess: GameState["goddess"] = { + ...prevGoddess, + bosses: [{ id: "divine_sentinel", status: "available", currentHp: 5000, maxHp: 5000 }] as GameState["goddess"]["bosses"], + }; + const prevState = makeState({ goddess: prevGoddess }); + const incomingState = makeState({ goddess: incomingGoddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await save({ state: incomingState }); + expect(res.status).toBe(200); + const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state; + const boss = savedState.goddess?.bosses.find((b) => b.id === "divine_sentinel"); + expect(boss?.status).toBe("defeated"); + }); + + it("restores goddess quest completed status when incoming tries to un-complete it", async () => { + const prevGoddess: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [{ id: "offering_ritual", status: "completed" }] as GameState["goddess"]["quests"], + totalPrayersEarned: 0, + upgrades: [], + zones: [], + }; + const incomingGoddess: GameState["goddess"] = { + ...prevGoddess, + quests: [{ id: "offering_ritual", status: "available" }] as GameState["goddess"]["quests"], + }; + const prevState = makeState({ goddess: prevGoddess }); + const incomingState = makeState({ goddess: incomingGoddess }); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never); + const res = await save({ state: incomingState }); + expect(res.status).toBe(200); + const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state; + const quest = savedState.goddess?.quests.find((q) => q.id === "offering_ritual"); + expect(quest?.status).toBe("completed"); + }); }); describe("GET /load error path", () => { diff --git a/apps/api/test/services/apotheosis.spec.ts b/apps/api/test/services/apotheosis.spec.ts index ead9b08..2eca315 100644 --- a/apps/api/test/services/apotheosis.spec.ts +++ b/apps/api/test/services/apotheosis.spec.ts @@ -112,4 +112,40 @@ describe("buildPostApotheosisState", () => { const { updatedState } = buildPostApotheosisState(state, "T"); expect(updatedState.apotheosis?.count).toBe(1); }); + + it("initialises goddess state on first apotheosis (count goes to 1)", () => { + const state = makeMinimalState(); + const { updatedState } = buildPostApotheosisState(state, "T"); + expect(updatedState.goddess).toBeDefined(); + }); + + it("preserves existing goddess state on second apotheosis (count goes to 2)", () => { + const goddessState: GameState["goddess"] = { + achievements: [], + baseClickPower: 1, + bosses: [], + consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] }, + disciples: [], + enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 }, + equipment: [], + exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] }, + lastTickAt: 0, + lifetimeBossesDefeated: 0, + lifetimePrayersEarned: 0, + lifetimeQuestsCompleted: 0, + quests: [], + totalPrayersEarned: 0, + upgrades: [], + zones: [], + }; + const state = makeMinimalState({ apotheosis: { count: 1 }, goddess: goddessState }); + const { updatedState } = buildPostApotheosisState(state, "T"); + expect(updatedState.goddess).toEqual(goddessState); + }); + + it("does not add goddess when count goes to 2 but no goddess exists on current state", () => { + const state = makeMinimalState({ apotheosis: { count: 1 } }); + const { updatedState } = buildPostApotheosisState(state, "T"); + expect(updatedState.goddess).toBeUndefined(); + }); }); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 3785835..3abf55e 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -11,19 +11,11 @@ export default defineConfig({ "src/index.ts", "src/data/materials.ts", // Goddess expansion data files — excluded until goddess routes import them in a later chunk - "src/data/goddessAchievements.ts", - "src/data/goddessBosses.ts", "src/data/goddessConsecrationUpgrades.ts", "src/data/goddessCrafting.ts", - "src/data/goddessDisciples.ts", "src/data/goddessEnlightenmentUpgrades.ts", - "src/data/goddessEquipment.ts", - "src/data/goddessExplorations.ts", - "src/data/goddessMaterials.ts", - "src/data/goddessQuests.ts", - "src/data/goddessUpgrades.ts", "src/data/goddessEquipmentSets.ts", - "src/data/goddessZones.ts", + "src/data/goddessMaterials.ts", ], thresholds: { statements: 100, diff --git a/goddess-todo.md b/goddess-todo.md index c214fa9..8332db2 100644 --- a/goddess-todo.md +++ b/goddess-todo.md @@ -46,10 +46,15 @@ Branch: `feat/goddess` - [ ] Goddess crafting route - [ ] Goddess exploration route -## Chunk 5 — UI: Resource Bar + Tab Row +## Chunk 5 — UI: Resource Bar + Mode/Tab Nav - [ ] Add goddess currencies to resource bar dropdown (greyed pre-apotheosis) -- [ ] Add second tab row to nav (always visible, locked pre-apotheosis) -- [ ] `.goddess-mode` CSS class toggle on root when goddess tab active +- [ ] Add **Mode bar** (Row 1) — `Mortal | Goddess | Vampire` — always visible, locked modes show padlock pre-unlock +- [ ] Add **Tab bar** (Row 2) — swaps entirely based on selected mode: + - Mortal: Zones · Quests · Adventurers · Equipment · Upgrades · Prestige · Transcendence · Crafting · Exploration · Achievements · Codex · Story · Daily + - Goddess: Zones · Disciples · Quests · Equipment · Upgrades · Consecration · Enlightenment · Crafting · Exploration · Achievements + - Vampire: *(future — TBD)* +- [ ] Persist selected mode in game state (survives page reload) +- [ ] `.goddess-mode` CSS class toggle on root when Goddess mode selected - [ ] 300ms CSS fade transition between base and goddess themes ## Chunk 6 — UI: Goddess Panels