generated from nhcarrigan/template
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)
This commit is contained in:
@@ -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 };
|
||||
|
||||
+114
-18
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user