generated from nhcarrigan/template
Compare commits
6 Commits
e7f0a9d537
...
2a3c20dc45
| Author | SHA1 | Date | |
|---|---|---|---|
|
2a3c20dc45
|
|||
|
b3913cef52
|
|||
|
050e34e6cd
|
|||
|
e808d92909
|
|||
|
b85126c345
|
|||
|
0c7a5f50fc
|
@@ -148,11 +148,22 @@ craftRouter.post("/", async(context) => {
|
||||
|
||||
const bonusType = recipe.bonus.type;
|
||||
const bonusValue = recipe.bonus.value;
|
||||
const { materials } = state.exploration;
|
||||
const {
|
||||
craftedGoldMultiplier,
|
||||
craftedEssenceMultiplier,
|
||||
craftedClickMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
} = updatedMultipliers;
|
||||
const response: CraftRecipeResponse = {
|
||||
bonusType,
|
||||
bonusValue,
|
||||
craftedClickMultiplier,
|
||||
craftedCombatMultiplier,
|
||||
craftedEssenceMultiplier,
|
||||
craftedGoldMultiplier,
|
||||
materials,
|
||||
recipeId,
|
||||
...updatedMultipliers,
|
||||
};
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
|
||||
+328
-22
@@ -20,6 +20,7 @@ import { defaultEquipment } from "../data/equipment.js";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { defaultQuests } from "../data/quests.js";
|
||||
import { defaultRecipes } from "../data/recipes.js";
|
||||
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||
import { defaultUpgrades } from "../data/upgrades.js";
|
||||
import { defaultZones } from "../data/zones.js";
|
||||
@@ -586,6 +587,8 @@ const patchQuestRewards = (state: GameState): number => {
|
||||
return `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
||||
}));
|
||||
for (const reward of defaultQuest.rewards) {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const key = `${reward.type}:${String(reward.targetId ?? reward.amount ?? "")}`;
|
||||
if (!existingKeys.has(key)) {
|
||||
savedQuest.rewards.push(structuredClone(reward));
|
||||
@@ -623,38 +626,321 @@ const patchBossUpgradeRewards = (state: GameState): number => {
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing adventurers to match the current defaults,
|
||||
* preserving only player-state fields (count and unlocked status).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of adventurer entries whose stats were updated.
|
||||
*/
|
||||
const patchAdventurerStats = (state: GameState): number => {
|
||||
const defaultAdventurerMap = new Map(defaultAdventurers.map((adventurer) => {
|
||||
return [ adventurer.id, adventurer ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedAdventurer of state.adventurers) {
|
||||
const defaultAdventurer = defaultAdventurerMap.get(savedAdventurer.id);
|
||||
if (defaultAdventurer === undefined) {
|
||||
continue;
|
||||
}
|
||||
savedAdventurer.baseCost = defaultAdventurer.baseCost;
|
||||
savedAdventurer.class = defaultAdventurer.class;
|
||||
savedAdventurer.combatPower = defaultAdventurer.combatPower;
|
||||
savedAdventurer.essencePerSecond = defaultAdventurer.essencePerSecond;
|
||||
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
|
||||
savedAdventurer.level = defaultAdventurer.level;
|
||||
savedAdventurer.name = defaultAdventurer.name;
|
||||
patched = patched + 1;
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing quests to match the current defaults,
|
||||
* preserving only player-state fields (status, startedAt, lastFailedAt, rewards).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of quest entries whose stats were updated.
|
||||
*/
|
||||
const patchQuestStats = (state: GameState): number => {
|
||||
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
|
||||
return [ quest.id, quest ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedQuest of state.quests) {
|
||||
const defaultQuest = defaultQuestMap.get(savedQuest.id);
|
||||
if (defaultQuest === undefined) {
|
||||
continue;
|
||||
}
|
||||
savedQuest.name = defaultQuest.name;
|
||||
savedQuest.description = defaultQuest.description;
|
||||
savedQuest.durationSeconds = defaultQuest.durationSeconds;
|
||||
savedQuest.prerequisiteIds = defaultQuest.prerequisiteIds;
|
||||
savedQuest.zoneId = defaultQuest.zoneId;
|
||||
if (defaultQuest.combatPowerRequired !== undefined) {
|
||||
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
|
||||
}
|
||||
patched = patched + 1;
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing bosses to match the current defaults,
|
||||
* preserving only player-state fields (status, currentHp, bountyRunestonesClaimed, upgradeRewards).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of boss entries whose stats were updated.
|
||||
*/
|
||||
const patchBossStats = (state: GameState): number => {
|
||||
const defaultBossMap = new Map(defaultBosses.map((boss) => {
|
||||
return [ boss.id, boss ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedBoss of state.bosses) {
|
||||
const defaultBoss = defaultBossMap.get(savedBoss.id);
|
||||
if (defaultBoss === undefined) {
|
||||
continue;
|
||||
}
|
||||
savedBoss.name = defaultBoss.name;
|
||||
savedBoss.description = defaultBoss.description;
|
||||
savedBoss.maxHp = defaultBoss.maxHp;
|
||||
savedBoss.damagePerSecond = defaultBoss.damagePerSecond;
|
||||
savedBoss.goldReward = defaultBoss.goldReward;
|
||||
savedBoss.essenceReward = defaultBoss.essenceReward;
|
||||
savedBoss.crystalReward = defaultBoss.crystalReward;
|
||||
savedBoss.equipmentRewards = [ ...defaultBoss.equipmentRewards ];
|
||||
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
|
||||
savedBoss.zoneId = defaultBoss.zoneId;
|
||||
savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
|
||||
patched = patched + 1;
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing zones to match the current defaults,
|
||||
* preserving only player-state fields (status).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of zone entries whose stats were updated.
|
||||
*/
|
||||
const patchZoneStats = (state: GameState): number => {
|
||||
const defaultZoneMap = new Map(defaultZones.map((zone) => {
|
||||
return [ zone.id, zone ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedZone of state.zones) {
|
||||
const defaultZone = defaultZoneMap.get(savedZone.id);
|
||||
if (defaultZone === undefined) {
|
||||
continue;
|
||||
}
|
||||
savedZone.name = defaultZone.name;
|
||||
savedZone.description = defaultZone.description;
|
||||
savedZone.emoji = defaultZone.emoji;
|
||||
savedZone.unlockBossId = defaultZone.unlockBossId;
|
||||
savedZone.unlockQuestId = defaultZone.unlockQuestId;
|
||||
patched = patched + 1;
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing upgrades to match the current defaults,
|
||||
* preserving only player-state fields (purchased, unlocked).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of upgrade entries whose stats were updated.
|
||||
*/
|
||||
const patchUpgradeStats = (state: GameState): number => {
|
||||
const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => {
|
||||
return [ upgrade.id, upgrade ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedUpgrade of state.upgrades) {
|
||||
const defaultUpgrade = defaultUpgradeMap.get(savedUpgrade.id);
|
||||
if (defaultUpgrade === undefined) {
|
||||
continue;
|
||||
}
|
||||
savedUpgrade.name = defaultUpgrade.name;
|
||||
savedUpgrade.description = defaultUpgrade.description;
|
||||
savedUpgrade.target = defaultUpgrade.target;
|
||||
if (defaultUpgrade.adventurerId !== undefined) {
|
||||
savedUpgrade.adventurerId = defaultUpgrade.adventurerId;
|
||||
}
|
||||
savedUpgrade.multiplier = defaultUpgrade.multiplier;
|
||||
savedUpgrade.costGold = defaultUpgrade.costGold;
|
||||
savedUpgrade.costEssence = defaultUpgrade.costEssence;
|
||||
savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
|
||||
patched = patched + 1;
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing equipment items to match the current defaults,
|
||||
* preserving only player-state fields (owned, equipped).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of equipment entries whose stats were updated.
|
||||
*/
|
||||
const patchEquipmentStats = (state: GameState): number => {
|
||||
const defaultEquipmentMap = new Map(defaultEquipment.map((item) => {
|
||||
return [ item.id, item ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedItem of state.equipment) {
|
||||
const defaultItem = defaultEquipmentMap.get(savedItem.id);
|
||||
if (defaultItem === undefined) {
|
||||
continue;
|
||||
}
|
||||
savedItem.name = defaultItem.name;
|
||||
savedItem.description = defaultItem.description;
|
||||
savedItem.type = defaultItem.type;
|
||||
savedItem.rarity = defaultItem.rarity;
|
||||
savedItem.bonus = structuredClone(defaultItem.bonus);
|
||||
if (defaultItem.cost !== undefined) {
|
||||
savedItem.cost = { ...defaultItem.cost };
|
||||
}
|
||||
if (defaultItem.setId !== undefined) {
|
||||
savedItem.setId = defaultItem.setId;
|
||||
}
|
||||
patched = patched + 1;
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the stat fields of existing achievements to match the current defaults,
|
||||
* preserving only player-state fields (unlockedAt).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of achievement entries whose stats were updated.
|
||||
*/
|
||||
const patchAchievementStats = (state: GameState): number => {
|
||||
const defaultAchievementMap = new Map(defaultAchievements.map((a) => {
|
||||
return [ a.id, a ] as const;
|
||||
}));
|
||||
let patched = 0;
|
||||
for (const savedAchievement of state.achievements) {
|
||||
const defaultAchievement = defaultAchievementMap.get(savedAchievement.id);
|
||||
if (defaultAchievement === undefined) {
|
||||
continue;
|
||||
}
|
||||
savedAchievement.name = defaultAchievement.name;
|
||||
savedAchievement.description = defaultAchievement.description;
|
||||
savedAchievement.icon = defaultAchievement.icon;
|
||||
savedAchievement.condition = structuredClone(defaultAchievement.condition);
|
||||
if (defaultAchievement.reward !== undefined) {
|
||||
savedAchievement.reward = { ...defaultAchievement.reward };
|
||||
}
|
||||
patched = patched + 1;
|
||||
}
|
||||
return patched;
|
||||
};
|
||||
|
||||
/* eslint-disable stylistic/max-len -- Filter conditions cannot be shortened without losing readability */
|
||||
/**
|
||||
* Recomputes all four crafting multipliers from the player's craftedRecipeIds,
|
||||
* replacing any stale cached values with the correct product of all crafted bonuses.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of crafted recipe IDs that were processed, or 0 if exploration is undefined.
|
||||
*/
|
||||
const recomputeCraftingMultipliers = (state: GameState): number => {
|
||||
if (state.exploration === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const { craftedRecipeIds } = state.exploration;
|
||||
state.exploration.craftedGoldMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "gold_income";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
state.exploration.craftedEssenceMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "essence_income";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
state.exploration.craftedClickMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "click_power";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
state.exploration.craftedCombatMultiplier = defaultRecipes.filter((recipe) => {
|
||||
return craftedRecipeIds.includes(recipe.id) && recipe.bonus.type === "combat_power";
|
||||
}).reduce((multiplier, recipe) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return multiplier * recipe.bonus.value;
|
||||
}, 1);
|
||||
return craftedRecipeIds.length;
|
||||
};
|
||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||
|
||||
/* eslint-disable stylistic/max-len -- Long function call lines cannot be shortened without losing alignment */
|
||||
/**
|
||||
* Syncs a player's save with the current game data, injecting any content
|
||||
* entries that are missing because they were added after the save was created.
|
||||
* entries that are missing because they were added after the save was created,
|
||||
* and patching stat fields on existing entries to match the current defaults.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns Counts of how many entries were added per content type.
|
||||
* @returns Counts of how many entries were added or patched per content type.
|
||||
*/
|
||||
const syncNewContent = (
|
||||
state: GameState,
|
||||
): {
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
bossRewardsPatched: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
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;
|
||||
} => {
|
||||
const adventurerStatsPatched = patchAdventurerStats(state);
|
||||
const questsPatched = patchQuestStats(state);
|
||||
const bossesPatched = patchBossStats(state);
|
||||
const zonesPatched = patchZoneStats(state);
|
||||
const upgradesPatched = patchUpgradeStats(state);
|
||||
const equipmentPatched = patchEquipmentStats(state);
|
||||
const achievementsPatched = patchAchievementStats(state);
|
||||
const craftingRecipesReapplied = recomputeCraftingMultipliers(state);
|
||||
const achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements);
|
||||
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
|
||||
const bossRewardsPatched = patchBossUpgradeRewards(state);
|
||||
const bossesAdded = injectMissingEntries(state.bosses, defaultBosses);
|
||||
const equipmentAdded = injectMissingEntries(state.equipment, defaultEquipment);
|
||||
const explorationAreasAdded = injectMissingExplorationAreas(state);
|
||||
const questRewardsPatched = patchQuestRewards(state);
|
||||
const questsAdded = injectMissingEntries(state.quests, defaultQuests);
|
||||
const upgradesAdded = injectMissingEntries(state.upgrades, defaultUpgrades);
|
||||
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
|
||||
return {
|
||||
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements),
|
||||
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers),
|
||||
bossRewardsPatched: patchBossUpgradeRewards(state),
|
||||
bossesAdded: injectMissingEntries(state.bosses, defaultBosses),
|
||||
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment),
|
||||
explorationAreasAdded: injectMissingExplorationAreas(state),
|
||||
questRewardsPatched: patchQuestRewards(state),
|
||||
questsAdded: injectMissingEntries(state.quests, defaultQuests),
|
||||
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades),
|
||||
zonesAdded: injectMissingEntries(state.zones, defaultZones),
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
adventurerStatsPatched,
|
||||
adventurersAdded,
|
||||
bossRewardsPatched,
|
||||
bossesAdded,
|
||||
bossesPatched,
|
||||
craftingRecipesReapplied,
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
};
|
||||
};
|
||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||
@@ -739,13 +1025,23 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
|
||||
const {
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
adventurersAdded,
|
||||
adventurerStatsPatched,
|
||||
bossesAdded,
|
||||
bossesPatched,
|
||||
bossRewardsPatched,
|
||||
craftingRecipesReapplied,
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
} = syncNewContent(state);
|
||||
|
||||
const updatedAt = Date.now();
|
||||
@@ -763,15 +1059,25 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
|
||||
return context.json({
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
adventurerStatsPatched,
|
||||
adventurersAdded,
|
||||
bossRewardsPatched,
|
||||
bossesAdded,
|
||||
bossesPatched,
|
||||
craftingRecipesReapplied,
|
||||
equipmentAdded,
|
||||
equipmentPatched,
|
||||
explorationAreasAdded,
|
||||
questRewardsPatched,
|
||||
questsAdded,
|
||||
questsPatched,
|
||||
signature,
|
||||
state,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable max-lines -- Route file requires multiple handlers */
|
||||
import { Hono } from "hono";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialExploration } from "../data/initialState.js";
|
||||
@@ -15,6 +16,7 @@ import { authMiddleware } from "../middleware/auth.js";
|
||||
import { logger } from "../services/logger.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
@@ -49,6 +51,64 @@ const pickNothingMessage = (): string => {
|
||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||
};
|
||||
|
||||
exploreRouter.get("/claimable", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
const areaId = context.req.query("areaId");
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime query validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultExplorations.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!explorationArea) {
|
||||
return context.json({ error: "Unknown exploration area" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
const rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
if (!state.exploration) {
|
||||
const response: ExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
|
||||
if (!area || area.status !== "in_progress") {
|
||||
const response: ExploreClaimableResponse = { claimable: false };
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
const claimable = Date.now() >= expiresAt;
|
||||
const response: ExploreClaimableResponse = { claimable };
|
||||
return context.json(response);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"explore_claimable",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
exploreRouter.post("/start", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
@@ -557,6 +557,459 @@ describe("debug route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const syncNewContent = () =>
|
||||
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
|
||||
|
||||
describe("POST /sync-new-content", () => {
|
||||
it("returns 404 when no game state found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 200 with zero added counts when state already has all content", 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 { adventurerStatsPatched: number; bossRewardsPatched: number; questRewardsPatched: number };
|
||||
expect(body.adventurerStatsPatched).toBe(0);
|
||||
expect(body.bossRewardsPatched).toBe(0);
|
||||
expect(body.questRewardsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches adventurer stats when saved adventurer has outdated stats", async () => {
|
||||
const state = makeState({
|
||||
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
|
||||
});
|
||||
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 { adventurerStatsPatched: number; state: GameState };
|
||||
expect(body.adventurerStatsPatched).toBe(1);
|
||||
const adventurer = body.state.adventurers.find((a) => a.id === "militia");
|
||||
expect(adventurer?.baseCost).not.toBe(1);
|
||||
expect(adventurer?.count).toBe(5);
|
||||
expect(adventurer?.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("skips adventurer stat patching for adventurers not in defaults", async () => {
|
||||
const state = makeState({
|
||||
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
|
||||
});
|
||||
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 { adventurerStatsPatched: number };
|
||||
expect(body.adventurerStatsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("injects missing entries when arrays are empty", async () => {
|
||||
const state = makeState({ adventurers: [], bosses: [], quests: [], upgrades: [], achievements: [], equipment: [], zones: [] });
|
||||
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 { adventurersAdded: number; bossesAdded: number };
|
||||
expect(body.adventurersAdded).toBeGreaterThan(0);
|
||||
expect(body.bossesAdded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("injects missing exploration areas when exploration has no areas", async () => {
|
||||
const state = makeState({ exploration: makeExploration([]) });
|
||||
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 { explorationAreasAdded: number };
|
||||
expect(body.explorationAreasAdded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips existing exploration areas when building the id set", async () => {
|
||||
const state = makeState({ exploration: makeExploration([
|
||||
{ id: "verdant_meadow", status: "available", completedOnce: false } as GameState["exploration"]["areas"][0],
|
||||
]) });
|
||||
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 { explorationAreasAdded: number };
|
||||
// One area already existed so total injected is one less than full count
|
||||
expect(body.explorationAreasAdded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns explorationAreasAdded=0 when exploration state is undefined", async () => {
|
||||
const state = makeState({ exploration: undefined as unknown as GameState["exploration"] });
|
||||
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 { explorationAreasAdded: number };
|
||||
expect(body.explorationAreasAdded).toBe(0);
|
||||
});
|
||||
|
||||
it("patches quest rewards when saved quest has fewer rewards than default", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "first_steps", status: "available", rewards: [] }] as GameState["quests"],
|
||||
});
|
||||
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 { state: GameState };
|
||||
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
||||
expect(quest?.rewards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips quest reward patching for quests not in defaults", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "nonexistent_quest", status: "available", rewards: [] }] as GameState["quests"],
|
||||
});
|
||||
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 { state: GameState };
|
||||
const quest = body.state.quests.find((q) => q.id === "nonexistent_quest");
|
||||
expect(quest?.rewards).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not re-add rewards that are already present in the saved quest", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "adventurer", targetId: "militia" }] }] as GameState["quests"],
|
||||
});
|
||||
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 { state: GameState };
|
||||
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
||||
// Reward already present so count stays the same
|
||||
expect(quest?.rewards.filter((r) => r.targetId === "militia").length).toBe(1);
|
||||
});
|
||||
|
||||
it("patches boss upgrade rewards when saved boss has fewer rewards than default", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "troll_king", status: "available", upgradeRewards: [] }] as GameState["bosses"],
|
||||
});
|
||||
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 { state: GameState };
|
||||
const boss = body.state.bosses.find((b) => b.id === "troll_king");
|
||||
expect(boss?.upgradeRewards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips boss reward patching for bosses not in defaults", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "nonexistent_boss", status: "available", upgradeRewards: [] }] as GameState["bosses"],
|
||||
});
|
||||
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 { state: GameState };
|
||||
const boss = body.state.bosses.find((b) => b.id === "nonexistent_boss");
|
||||
expect(boss?.upgradeRewards).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("sorts multiple legacy items to the end when their ids are not in the defaults", async () => {
|
||||
const state = makeState({
|
||||
achievements: [
|
||||
{ id: "legacy_achievement_a", status: "locked" },
|
||||
{ id: "legacy_achievement_b", status: "locked" },
|
||||
] as GameState["achievements"],
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it("uses amount field when building the reward key for quests with amount-based rewards", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "dragon_lair", status: "available", rewards: [{ type: "gold", amount: 500 }] }] as GameState["quests"],
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it("falls back to empty string when reward has neither targetId nor amount", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "unknown" }] }] as GameState["quests"],
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it("patches upgrade adventurerId when default has it set", async () => {
|
||||
const state = makeState({
|
||||
upgrades: [{ id: "peasant_1", purchased: false, unlocked: false, multiplier: 0.1, name: "Old", description: "Old", target: "click", costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
||||
});
|
||||
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 { state: GameState };
|
||||
const upgrade = body.state.upgrades.find((u) => u.id === "peasant_1");
|
||||
expect(upgrade?.adventurerId).toBe("peasant");
|
||||
});
|
||||
|
||||
it("patches equipment cost when default has it set", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "shadow_dagger", owned: false, equipped: false, name: "Old", description: "Old", type: "weapon", rarity: "common", bonus: {} }] as GameState["equipment"],
|
||||
});
|
||||
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 { state: GameState };
|
||||
const item = body.state.equipment.find((e) => e.id === "shadow_dagger");
|
||||
expect(item?.cost).toBeDefined();
|
||||
});
|
||||
|
||||
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
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 { signature: string | undefined };
|
||||
expect(body.signature).toBeDefined();
|
||||
delete process.env.ANTI_CHEAT_SECRET;
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("patches quest stats when saved quest has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "first_steps", status: "available", rewards: [], durationSeconds: 1, name: "Old Name", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
|
||||
});
|
||||
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 { questsPatched: number; state: GameState };
|
||||
expect(body.questsPatched).toBe(1);
|
||||
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
||||
expect(quest?.name).not.toBe("Old Name");
|
||||
expect(quest?.durationSeconds).not.toBe(1);
|
||||
expect(quest?.status).toBe("available");
|
||||
});
|
||||
|
||||
it("skips quest stat patching for quests not in defaults", async () => {
|
||||
const state = makeState({
|
||||
quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
|
||||
});
|
||||
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 { questsPatched: number };
|
||||
expect(body.questsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches boss stats when saved boss has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "troll_king", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Old Name", description: "Old" }] as GameState["bosses"],
|
||||
});
|
||||
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 { bossesPatched: number; state: GameState };
|
||||
expect(body.bossesPatched).toBe(1);
|
||||
const boss = body.state.bosses.find((b) => b.id === "troll_king");
|
||||
expect(boss?.maxHp).not.toBe(1);
|
||||
expect(boss?.name).not.toBe("Old Name");
|
||||
expect(boss?.status).toBe("available");
|
||||
expect(boss?.currentHp).toBe(100);
|
||||
});
|
||||
|
||||
it("skips boss stat patching for bosses not in defaults", async () => {
|
||||
const state = makeState({
|
||||
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
|
||||
});
|
||||
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 { bossesPatched: number };
|
||||
expect(body.bossesPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches zone stats when saved zone has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
zones: [{ id: "verdant_vale", status: "unlocked", name: "Old Name", description: "Old", emoji: "âť“", unlockBossId: "wrong_boss", unlockQuestId: "wrong_quest" }] as GameState["zones"],
|
||||
});
|
||||
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 { zonesPatched: number; state: GameState };
|
||||
expect(body.zonesPatched).toBe(1);
|
||||
const zone = body.state.zones.find((z) => z.id === "verdant_vale");
|
||||
expect(zone?.name).not.toBe("Old Name");
|
||||
expect(zone?.status).toBe("unlocked");
|
||||
});
|
||||
|
||||
it("skips zone stat patching for zones not in defaults", async () => {
|
||||
const state = makeState({
|
||||
zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "âť“", unlockBossId: null, unlockQuestId: null }] as GameState["zones"],
|
||||
});
|
||||
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 { zonesPatched: number };
|
||||
expect(body.zonesPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches upgrade stats when saved upgrade has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
upgrades: [{ id: "click_2", purchased: false, unlocked: true, multiplier: 0.1, name: "Old Name", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
||||
});
|
||||
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 { upgradesPatched: number; state: GameState };
|
||||
expect(body.upgradesPatched).toBe(1);
|
||||
const upgrade = body.state.upgrades.find((u) => u.id === "click_2");
|
||||
expect(upgrade?.multiplier).not.toBe(0.1);
|
||||
expect(upgrade?.name).not.toBe("Old Name");
|
||||
expect(upgrade?.purchased).toBe(false);
|
||||
expect(upgrade?.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("skips upgrade stat patching for upgrades not in defaults", async () => {
|
||||
const state = makeState({
|
||||
upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
||||
});
|
||||
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 { upgradesPatched: number };
|
||||
expect(body.upgradesPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches equipment stats when saved item has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Rusty Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
|
||||
});
|
||||
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 { equipmentPatched: number; state: GameState };
|
||||
expect(body.equipmentPatched).toBe(1);
|
||||
const item = body.state.equipment.find((e) => e.id === "iron_sword");
|
||||
expect(item?.name).not.toBe("Rusty Sword");
|
||||
expect(item?.owned).toBe(true);
|
||||
expect(item?.equipped).toBe(false);
|
||||
});
|
||||
|
||||
it("skips equipment stat patching for items not in defaults", async () => {
|
||||
const state = makeState({
|
||||
equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
|
||||
});
|
||||
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 { equipmentPatched: number };
|
||||
expect(body.equipmentPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("patches achievement stats when saved achievement has outdated fields", async () => {
|
||||
const state = makeState({
|
||||
achievements: [{ id: "first_click", unlockedAt: null, name: "Old Name", description: "Old", icon: "âť“", condition: { type: "totalClicks", amount: 999 }, reward: undefined }] as GameState["achievements"],
|
||||
});
|
||||
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 { achievementsPatched: number; state: GameState };
|
||||
expect(body.achievementsPatched).toBe(1);
|
||||
const achievement = body.state.achievements.find((a) => a.id === "first_click");
|
||||
expect(achievement?.name).not.toBe("Old Name");
|
||||
expect(achievement?.condition.amount).not.toBe(999);
|
||||
expect(achievement?.unlockedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("skips achievement stat patching for achievements not in defaults", async () => {
|
||||
const state = makeState({
|
||||
achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "âť“", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"],
|
||||
});
|
||||
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 { achievementsPatched: number };
|
||||
expect(body.achievementsPatched).toBe(0);
|
||||
});
|
||||
|
||||
it("recomputes crafting multipliers from craftedRecipeIds", async () => {
|
||||
const state = makeState({
|
||||
exploration: { ...makeExploration(), craftedRecipeIds: ["heartwood_tincture"], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
});
|
||||
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 { craftingRecipesReapplied: number; state: GameState };
|
||||
expect(body.craftingRecipesReapplied).toBe(1);
|
||||
expect(body.state.exploration?.craftedGoldMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns 0 for crafting recompute when exploration is undefined", async () => {
|
||||
const state = makeState({
|
||||
exploration: undefined as unknown as GameState["exploration"],
|
||||
});
|
||||
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 { craftingRecipesReapplied: number };
|
||||
expect(body.craftingRecipesReapplied).toBe(0);
|
||||
});
|
||||
|
||||
it("sets multipliers to 1 when craftedRecipeIds is empty", async () => {
|
||||
const state = makeState({
|
||||
exploration: { ...makeExploration(), craftedRecipeIds: [], craftedGoldMultiplier: 5, craftedEssenceMultiplier: 5, craftedClickMultiplier: 5, craftedCombatMultiplier: 5 },
|
||||
});
|
||||
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 { state: GameState };
|
||||
expect(body.state.exploration?.craftedGoldMultiplier).toBe(1);
|
||||
expect(body.state.exploration?.craftedEssenceMultiplier).toBe(1);
|
||||
expect(body.state.exploration?.craftedClickMultiplier).toBe(1);
|
||||
expect(body.state.exploration?.craftedCombatMultiplier).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /hard-reset", () => {
|
||||
it("returns 404 when no player found", async () => {
|
||||
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
@@ -77,6 +77,99 @@ describe("explore route", () => {
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
const getClaimable = (areaId?: string) => {
|
||||
const url = areaId === undefined
|
||||
? "http://localhost/explore/claimable"
|
||||
: `http://localhost/explore/claimable?areaId=${areaId}`;
|
||||
return app.fetch(new Request(url));
|
||||
};
|
||||
|
||||
describe("GET /claimable", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await getClaimable();
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await getClaimable("nonexistent_area");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns claimable=false when no exploration state exists", async () => {
|
||||
const state = makeState({ exploration: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when area is not in_progress", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when exploration is still in progress", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now(), completedOnce: false }] as GameState["exploration"]["areas"],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=true when exploration is complete", async () => {
|
||||
const state = makeState({
|
||||
exploration: {
|
||||
areas: [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0, completedOnce: false }] as GameState["exploration"]["areas"],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedGoldMultiplier: 1,
|
||||
craftedEssenceMultiplier: 1,
|
||||
craftedClickMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /start", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await postStart({});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
@@ -244,6 +245,19 @@ const collectExploration = async(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether a given exploration area is ready to claim on the server.
|
||||
* @param areaId - The area ID to check.
|
||||
* @returns Whether the exploration is claimable.
|
||||
*/
|
||||
const checkExplorationClaimable = async(
|
||||
areaId: string,
|
||||
): Promise<ExploreClaimableResponse> => {
|
||||
return await fetchJson<ExploreClaimableResponse>(
|
||||
`/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Crafts a recipe on the server.
|
||||
* @param body - The craft recipe request payload.
|
||||
@@ -316,6 +330,7 @@ export {
|
||||
buyEchoUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
challengeBoss,
|
||||
checkExplorationClaimable,
|
||||
collectExploration,
|
||||
craftRecipe,
|
||||
debugHardReset,
|
||||
|
||||
@@ -13,18 +13,30 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||
|
||||
interface SyncNewContentResult {
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
bossRewardsPatched: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
achievementsAdded: number | undefined;
|
||||
achievementsPatched: number | undefined;
|
||||
adventurersAdded: number | undefined;
|
||||
adventurerStatsPatched: number | undefined;
|
||||
bossesAdded: number | undefined;
|
||||
bossesPatched: number | undefined;
|
||||
bossRewardsPatched: number | undefined;
|
||||
craftingRecipesReapplied: number | undefined;
|
||||
equipmentAdded: number | undefined;
|
||||
equipmentPatched: number | undefined;
|
||||
explorationAreasAdded: number | undefined;
|
||||
questRewardsPatched: number | undefined;
|
||||
questsAdded: number | undefined;
|
||||
questsPatched: number | undefined;
|
||||
upgradesAdded: number | undefined;
|
||||
upgradesPatched: number | undefined;
|
||||
zonesAdded: number | undefined;
|
||||
zonesPatched: number | undefined;
|
||||
}
|
||||
|
||||
const safeNumber = (value: number | undefined): number => {
|
||||
return value ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a human-readable summary of what the sync-new-content operation added.
|
||||
* @param result - The counts returned by the operation.
|
||||
@@ -32,16 +44,24 @@ interface SyncNewContentResult {
|
||||
*/
|
||||
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||
const entries: Array<[ number, string ]> = [
|
||||
[ result.zonesAdded, "zone(s)" ],
|
||||
[ result.questsAdded, "quest(s)" ],
|
||||
[ result.questRewardsPatched, "quest reward(s) patched" ],
|
||||
[ result.bossesAdded, "boss(es)" ],
|
||||
[ result.bossRewardsPatched, "boss reward(s) patched" ],
|
||||
[ result.explorationAreasAdded, "exploration area(s)" ],
|
||||
[ result.adventurersAdded, "adventurer tier(s)" ],
|
||||
[ result.upgradesAdded, "upgrade(s)" ],
|
||||
[ result.equipmentAdded, "equipment item(s)" ],
|
||||
[ result.achievementsAdded, "achievement(s)" ],
|
||||
[ safeNumber(result.zonesAdded), "zone(s)" ],
|
||||
[ safeNumber(result.questsAdded), "quest(s)" ],
|
||||
[ safeNumber(result.questRewardsPatched), "quest reward(s) patched" ],
|
||||
[ safeNumber(result.bossesAdded), "boss(es)" ],
|
||||
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
|
||||
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
|
||||
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
|
||||
[ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ],
|
||||
[ safeNumber(result.upgradesAdded), "upgrade(s)" ],
|
||||
[ safeNumber(result.equipmentAdded), "equipment item(s)" ],
|
||||
[ safeNumber(result.achievementsAdded), "achievement(s)" ],
|
||||
[ safeNumber(result.questsPatched), "quest stat(s) patched" ],
|
||||
[ safeNumber(result.bossesPatched), "boss stat(s) patched" ],
|
||||
[ safeNumber(result.zonesPatched), "zone stat(s) patched" ],
|
||||
[ safeNumber(result.upgradesPatched), "upgrade stat(s) patched" ],
|
||||
[ safeNumber(result.equipmentPatched), "equipment stat(s) patched" ],
|
||||
[ safeNumber(result.achievementsPatched), "achievement stat(s) patched" ],
|
||||
[ safeNumber(result.craftingRecipesReapplied), "crafting recipe(s) reapplied" ],
|
||||
];
|
||||
const parts = entries.
|
||||
filter(([ count ]) => {
|
||||
@@ -60,14 +80,14 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||
};
|
||||
|
||||
interface ForceUnlocksResult {
|
||||
adventurersUnlocked: number;
|
||||
bossesUnlocked: number;
|
||||
equipmentUnlocked: number;
|
||||
explorationUnlocked: number;
|
||||
questsUnlocked: number;
|
||||
storyUnlocked: number;
|
||||
upgradesUnlocked: number;
|
||||
zonesUnlocked: number;
|
||||
adventurersUnlocked: number | undefined;
|
||||
bossesUnlocked: number | undefined;
|
||||
equipmentUnlocked: number | undefined;
|
||||
explorationUnlocked: number | undefined;
|
||||
questsUnlocked: number | undefined;
|
||||
storyUnlocked: number | undefined;
|
||||
upgradesUnlocked: number | undefined;
|
||||
zonesUnlocked: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,14 +97,14 @@ interface ForceUnlocksResult {
|
||||
*/
|
||||
const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
|
||||
const entries: Array<[ number, string ]> = [
|
||||
[ result.zonesUnlocked, "zone(s)" ],
|
||||
[ result.questsUnlocked, "quest(s)" ],
|
||||
[ result.bossesUnlocked, "boss(es)" ],
|
||||
[ result.explorationUnlocked, "exploration area(s)" ],
|
||||
[ result.adventurersUnlocked, "adventurer tier(s)" ],
|
||||
[ result.upgradesUnlocked, "upgrade(s)" ],
|
||||
[ result.equipmentUnlocked, "equipment item(s)" ],
|
||||
[ result.storyUnlocked, "story chapter(s)" ],
|
||||
[ safeNumber(result.zonesUnlocked), "zone(s)" ],
|
||||
[ safeNumber(result.questsUnlocked), "quest(s)" ],
|
||||
[ safeNumber(result.bossesUnlocked), "boss(es)" ],
|
||||
[ safeNumber(result.explorationUnlocked), "exploration area(s)" ],
|
||||
[ safeNumber(result.adventurersUnlocked), "adventurer tier(s)" ],
|
||||
[ safeNumber(result.upgradesUnlocked), "upgrade(s)" ],
|
||||
[ safeNumber(result.equipmentUnlocked), "equipment item(s)" ],
|
||||
[ safeNumber(result.storyUnlocked), "story chapter(s)" ],
|
||||
];
|
||||
const parts = entries.
|
||||
filter(([ count ]) => {
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many conditional render paths */
|
||||
/* eslint-disable max-lines -- Exploration panel requires many render paths and result display */
|
||||
import { type JSX, useState } from "react";
|
||||
/* eslint-disable max-statements -- Component function requires many state declarations and handlers */
|
||||
import { type JSX, useEffect, useRef, useState } from "react";
|
||||
import { checkExplorationClaimable } from "../../api/client.js";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EXPLORATION_AREAS } from "../../data/explorations.js";
|
||||
import { cdnImage } from "../../utils/cdn.js";
|
||||
import { ZoneSelector } from "./zoneSelector.js";
|
||||
import type { ExploreCollectResponse } from "@elysium/types";
|
||||
import type {
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds to a human-readable string.
|
||||
@@ -83,6 +88,61 @@ const ExplorationPanel = (): JSX.Element => {
|
||||
});
|
||||
const [ pendingAreaId, setPendingAreaId ] = useState<string | null>(null);
|
||||
const [ lastResult, setLastResult ] = useState<CollectResult | null>(null);
|
||||
const [ claimableAreaIds, setClaimableAreaIds ]
|
||||
= useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const stateReference = useRef(state);
|
||||
stateReference.current = state;
|
||||
|
||||
const claimableReference = useRef(claimableAreaIds);
|
||||
claimableReference.current = claimableAreaIds;
|
||||
|
||||
useEffect(() => {
|
||||
const pollClaimable = async(): Promise<void> => {
|
||||
const currentState = stateReference.current;
|
||||
if (currentState === null) {
|
||||
return;
|
||||
}
|
||||
const inProgressArea = currentState.exploration?.areas.find((a) => {
|
||||
return a.status === "in_progress";
|
||||
});
|
||||
if (inProgressArea === undefined) {
|
||||
return;
|
||||
}
|
||||
if (claimableReference.current.has(inProgressArea.id)) {
|
||||
return;
|
||||
}
|
||||
const areaData = EXPLORATION_AREAS.find((a) => {
|
||||
return a.id === inProgressArea.id;
|
||||
});
|
||||
if (areaData === undefined) {
|
||||
return;
|
||||
}
|
||||
const remaining = timeRemaining(
|
||||
inProgressArea.endsAt,
|
||||
inProgressArea.startedAt ?? 0,
|
||||
areaData.durationSeconds,
|
||||
);
|
||||
if (remaining > 0) {
|
||||
return;
|
||||
}
|
||||
const result: ExploreClaimableResponse
|
||||
= await checkExplorationClaimable(inProgressArea.id);
|
||||
if (result.claimable) {
|
||||
setClaimableAreaIds((previous) => {
|
||||
return new Set([ ...previous, inProgressArea.id ]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void pollClaimable();
|
||||
}, 1000);
|
||||
|
||||
return (): void => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
@@ -134,6 +194,11 @@ const ExplorationPanel = (): JSX.Element => {
|
||||
try {
|
||||
const result = await collectExploration(areaId);
|
||||
setLastResult({ areaId: areaId, response: result });
|
||||
setClaimableAreaIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(areaId);
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setPendingAreaId(null);
|
||||
}
|
||||
@@ -269,7 +334,7 @@ const ExplorationPanel = (): JSX.Element => {
|
||||
const endsAt = areaState?.endsAt;
|
||||
const isReady
|
||||
= status === "in_progress"
|
||||
&& timeRemaining(endsAt, startedAt, area.durationSeconds) <= 0;
|
||||
&& claimableAreaIds.has(area.id);
|
||||
const isPending = pendingAreaId === area.id;
|
||||
|
||||
function handleStartClick(): void {
|
||||
|
||||
@@ -1864,14 +1864,6 @@ export const GameProvider = ({
|
||||
if (previous?.exploration === undefined) {
|
||||
return previous;
|
||||
}
|
||||
let materials = [ ...previous.exploration.materials ];
|
||||
for (const request of recipe.requiredMaterials) {
|
||||
materials = materials.map((mat) => {
|
||||
return mat.materialId === request.materialId
|
||||
? { ...mat, quantity: mat.quantity - request.quantity }
|
||||
: mat;
|
||||
});
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
exploration: {
|
||||
@@ -1884,7 +1876,7 @@ export const GameProvider = ({
|
||||
...previous.exploration.craftedRecipeIds,
|
||||
recipeId,
|
||||
],
|
||||
materials: materials,
|
||||
materials: result.materials,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ export type {
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- API types file grows with each new endpoint */
|
||||
import type {
|
||||
EquipmentBonus,
|
||||
EquipmentRarity,
|
||||
@@ -384,6 +385,10 @@ interface ExploreCollectResponse {
|
||||
event: ExploreCollectEventResult | null;
|
||||
}
|
||||
|
||||
interface ExploreClaimableResponse {
|
||||
claimable: boolean;
|
||||
}
|
||||
|
||||
interface CraftRecipeRequest {
|
||||
recipeId: string;
|
||||
}
|
||||
@@ -396,6 +401,7 @@ interface CraftRecipeResponse {
|
||||
craftedEssenceMultiplier: number;
|
||||
craftedClickMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
materials: Array<{ materialId: string; quantity: number }>;
|
||||
}
|
||||
|
||||
interface ForceUnlocksResponse {
|
||||
@@ -463,6 +469,11 @@ interface SyncNewContentResponse {
|
||||
*/
|
||||
adventurersAdded: number;
|
||||
|
||||
/**
|
||||
* Number of existing adventurer entries whose stats were patched to match current defaults.
|
||||
*/
|
||||
adventurerStatsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of upgrades added to the save.
|
||||
*/
|
||||
@@ -508,6 +519,41 @@ interface SyncNewContentResponse {
|
||||
*/
|
||||
explorationAreasAdded: number;
|
||||
|
||||
/**
|
||||
* Number of achievements whose stats were updated to match current defaults.
|
||||
*/
|
||||
achievementsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of bosses whose stats were updated to match current defaults.
|
||||
*/
|
||||
bossesPatched: number;
|
||||
|
||||
/**
|
||||
* Number of crafted recipes whose multiplier contribution was reapplied during recompute.
|
||||
*/
|
||||
craftingRecipesReapplied: number;
|
||||
|
||||
/**
|
||||
* Number of equipment items whose stats were updated to match current defaults.
|
||||
*/
|
||||
equipmentPatched: number;
|
||||
|
||||
/**
|
||||
* Number of quests whose stats were updated to match current defaults.
|
||||
*/
|
||||
questsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of upgrades whose stats were updated to match current defaults.
|
||||
*/
|
||||
upgradesPatched: number;
|
||||
|
||||
/**
|
||||
* Number of zones whose stats were updated to match current defaults.
|
||||
*/
|
||||
zonesPatched: number;
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||
*/
|
||||
@@ -528,6 +574,7 @@ export type {
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
|
||||
Reference in New Issue
Block a user