generated from nhcarrigan/template
Compare commits
10 Commits
v0.3.0
...
2a3c20dc45
| Author | SHA1 | Date | |
|---|---|---|---|
|
2a3c20dc45
|
|||
|
b3913cef52
|
|||
|
050e34e6cd
|
|||
|
e808d92909
|
|||
|
b85126c345
|
|||
|
0c7a5f50fc
|
|||
|
790d35420f
|
|||
|
9f9edae45e
|
|||
|
a7a255dab6
|
|||
|
e92cf3c9a1
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/api",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -13,11 +13,16 @@ import {
|
||||
type GameState,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { defaultAchievements } from "../data/achievements.js";
|
||||
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 { 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";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
@@ -508,6 +513,438 @@ const applyForceUnlocks = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects any entries from a defaults array that are missing from an existing
|
||||
* saved array (matched by `id`), cloning each new entry before pushing.
|
||||
* @param existing - The player's saved array (mutated in place).
|
||||
* @param defaults - The current default data array to compare against.
|
||||
* @returns The number of entries that were added.
|
||||
*/
|
||||
const injectMissingEntries = <T extends { id: string }>(
|
||||
existing: Array<T>,
|
||||
defaults: Array<T>,
|
||||
): number => {
|
||||
const existingIds = new Set(existing.map((item) => {
|
||||
return item.id;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const item of defaults) {
|
||||
if (!existingIds.has(item.id)) {
|
||||
existing.push(structuredClone(item));
|
||||
added = added + 1;
|
||||
}
|
||||
}
|
||||
const defaultOrder = new Map(defaults.map((item, index) => {
|
||||
return [ item.id, index ] as const;
|
||||
}));
|
||||
existing.sort((itemA, itemB) => {
|
||||
return (defaultOrder.get(itemA.id) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (defaultOrder.get(itemB.id) ?? Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects any exploration areas from the defaults that are missing from the
|
||||
* player's exploration state, seeding each new area as locked.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of exploration areas that were added.
|
||||
*/
|
||||
const injectMissingExplorationAreas = (state: GameState): number => {
|
||||
if (state.exploration === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const existingIds = new Set(state.exploration.areas.map((area) => {
|
||||
return area.id;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const area of defaultExplorations) {
|
||||
if (!existingIds.has(area.id)) {
|
||||
state.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).
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The total number of individual rewards that were added.
|
||||
*/
|
||||
const patchQuestRewards = (state: GameState): number => {
|
||||
const defaultQuestMap = new Map(defaultQuests.map((quest) => {
|
||||
return [ quest.id, quest ] as const;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const savedQuest of state.quests) {
|
||||
const defaultQuest = defaultQuestMap.get(savedQuest.id);
|
||||
if (defaultQuest === undefined) {
|
||||
continue;
|
||||
}
|
||||
const existingKeys = new Set(savedQuest.rewards.map((reward) => {
|
||||
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));
|
||||
added = added + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Patches upgradeRewards on existing bosses whose reward lists have grown
|
||||
* since the save was created.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The total number of upgrade reward IDs that were added.
|
||||
*/
|
||||
const patchBossUpgradeRewards = (state: GameState): number => {
|
||||
const defaultBossMap = new Map(defaultBosses.map((boss) => {
|
||||
return [ boss.id, boss ] as const;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const savedBoss of state.bosses) {
|
||||
const defaultBoss = defaultBossMap.get(savedBoss.id);
|
||||
if (defaultBoss === undefined) {
|
||||
continue;
|
||||
}
|
||||
const existingIds = new Set(savedBoss.upgradeRewards);
|
||||
for (const upgradeId of defaultBoss.upgradeRewards) {
|
||||
if (!existingIds.has(upgradeId)) {
|
||||
savedBoss.upgradeRewards.push(upgradeId);
|
||||
added = added + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
* 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 or patched 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;
|
||||
} => {
|
||||
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,
|
||||
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 */
|
||||
|
||||
const debugRouter = new Hono<HonoEnvironment>();
|
||||
debugRouter.use(authMiddleware);
|
||||
|
||||
@@ -572,6 +1009,87 @@ debugRouter.post("/force-unlocks", async(context) => {
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/sync-new-content", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const gameStateRecord = await prisma.gameState.findUnique({
|
||||
where: { discordId },
|
||||
});
|
||||
if (!gameStateRecord) {
|
||||
return context.json({ error: "No game state found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma stores state as JSON object */
|
||||
const state = gameStateRecord.state as unknown as GameState;
|
||||
|
||||
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();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: updatedAt },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature
|
||||
= secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(state), secret);
|
||||
|
||||
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(
|
||||
"debug_sync_new_content",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
return context.json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/hard-reset", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/web",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
@@ -243,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.
|
||||
@@ -267,6 +282,16 @@ const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncs any content added after the player's save was created into their save.
|
||||
* @returns The updated game state and counts of what was added per content type.
|
||||
*/
|
||||
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
|
||||
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs a complete hard reset of the player's game state via the debug endpoint.
|
||||
* @returns The fresh game state as a LoadResponse.
|
||||
@@ -305,10 +330,12 @@ export {
|
||||
buyEchoUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
challengeBoss,
|
||||
checkExplorationClaimable,
|
||||
collectExploration,
|
||||
craftRecipe,
|
||||
debugHardReset,
|
||||
forceUnlocks,
|
||||
syncNewContent,
|
||||
getAbout,
|
||||
getAuthUrl,
|
||||
getPublicProfile,
|
||||
|
||||
@@ -10,17 +10,84 @@ import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { ConfirmationModal } from "../ui/confirmationModal.js";
|
||||
|
||||
type ActiveModal = "force-unlocks" | "hard-reset" | null;
|
||||
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||
|
||||
interface SyncNewContentResult {
|
||||
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.
|
||||
* @returns A message string describing what was added, or a confirmation nothing was needed.
|
||||
*/
|
||||
const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
||||
const entries: Array<[ number, string ]> = [
|
||||
[ 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 ]) => {
|
||||
return count > 0;
|
||||
}).
|
||||
map(([ count, label ]) => {
|
||||
return `${String(count)} ${label}`;
|
||||
});
|
||||
if (parts.length === 0) {
|
||||
return "Your save is already up to date — no new content was found.";
|
||||
}
|
||||
const total = entries.reduce((sum, [ count ]) => {
|
||||
return sum + count;
|
||||
}, 0);
|
||||
return `Synced ${String(total)} item(s): ${parts.join(", ")}.`;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,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 ]) => {
|
||||
@@ -60,15 +127,21 @@ const buildForceUnlocksMessage = (result: ForceUnlocksResult): string => {
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const DebugPanel = (): JSX.Element => {
|
||||
const { forceUnlocks, debugHardReset, isLoading } = useGame();
|
||||
const { forceUnlocks, debugHardReset, syncNewContent, isLoading } = useGame();
|
||||
const [ activeModal, setActiveModal ] = useState<ActiveModal>(null);
|
||||
const [ forceUnlocksResult, setForceUnlocksResult ] = useState<string | null>(null);
|
||||
const [ syncNewContentResult, setSyncNewContentResult ] = useState<string | null>(null);
|
||||
|
||||
function handleOpenForceUnlocks(): void {
|
||||
setForceUnlocksResult(null);
|
||||
setActiveModal("force-unlocks");
|
||||
}
|
||||
|
||||
function handleOpenSyncNewContent(): void {
|
||||
setSyncNewContentResult(null);
|
||||
setActiveModal("sync-new-content");
|
||||
}
|
||||
|
||||
function handleOpenHardReset(): void {
|
||||
setActiveModal("hard-reset");
|
||||
}
|
||||
@@ -85,6 +158,14 @@ const DebugPanel = (): JSX.Element => {
|
||||
})();
|
||||
}
|
||||
|
||||
function handleConfirmSyncNewContent(): void {
|
||||
setActiveModal(null);
|
||||
void (async(): Promise<void> => {
|
||||
const result = await syncNewContent();
|
||||
setSyncNewContentResult(buildSyncNewContentMessage(result));
|
||||
})();
|
||||
}
|
||||
|
||||
function handleConfirmHardReset(): void {
|
||||
setActiveModal(null);
|
||||
void debugHardReset();
|
||||
@@ -120,6 +201,26 @@ const DebugPanel = (): JSX.Element => {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="debug-action-card">
|
||||
<h3>{"🔄 Sync New Content"}</h3>
|
||||
<p>
|
||||
{
|
||||
"If the game has been updated since your save was created, this will add any missing adventurers, quests, bosses, equipment, upgrades, and more to your save without affecting your existing progress."
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
className="action-button"
|
||||
disabled={isLoading}
|
||||
onClick={handleOpenSyncNewContent}
|
||||
type="button"
|
||||
>
|
||||
{"Sync New Content"}
|
||||
</button>
|
||||
{syncNewContentResult !== null
|
||||
&& <p className="debug-result-message">{syncNewContentResult}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="debug-action-card">
|
||||
<h3>{"đź’€ Hard Reset"}</h3>
|
||||
<p>
|
||||
@@ -149,6 +250,17 @@ const DebugPanel = (): JSX.Element => {
|
||||
/>
|
||||
}
|
||||
|
||||
{activeModal === "sync-new-content"
|
||||
&& <ConfirmationModal
|
||||
confirmLabel="Yes, Sync New Content"
|
||||
description="This will scan for any adventurers, quests, bosses, equipment, upgrades, achievements, and zones added to the game after your save was created, and add them to your save. This operation is safe and non-destructive — your existing progress will not be affected."
|
||||
isLoading={isLoading}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirmSyncNewContent}
|
||||
title="Sync New Content"
|
||||
/>
|
||||
}
|
||||
|
||||
{activeModal === "hard-reset"
|
||||
&& <ConfirmationModal
|
||||
confirmLabel="Yes, Wipe Everything"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
craftRecipe as craftRecipeApi,
|
||||
debugHardReset as debugHardResetApi,
|
||||
forceUnlocks as forceUnlocksApi,
|
||||
syncNewContent as syncNewContentApi,
|
||||
loadGame,
|
||||
prestige as prestigeApi,
|
||||
resetProgress as resetProgressApi,
|
||||
@@ -574,6 +575,23 @@ interface GameContextValue {
|
||||
*/
|
||||
debugHardReset: ()=> Promise<void>;
|
||||
|
||||
/**
|
||||
* Syncs any content added to the game after the player's save was created.
|
||||
* @returns Counts of what was added per content type.
|
||||
*/
|
||||
syncNewContent: ()=> Promise<{
|
||||
achievementsAdded: number;
|
||||
adventurersAdded: number;
|
||||
bossesAdded: number;
|
||||
bossRewardsPatched: number;
|
||||
equipmentAdded: number;
|
||||
explorationAreasAdded: number;
|
||||
questRewardsPatched: number;
|
||||
questsAdded: number;
|
||||
upgradesAdded: number;
|
||||
zonesAdded: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Last auto-boss fight result — null until the first auto fight completes or
|
||||
* when auto-boss is toggled off.
|
||||
@@ -1846,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: {
|
||||
@@ -1866,7 +1876,7 @@ export const GameProvider = ({
|
||||
...previous.exploration.craftedRecipeIds,
|
||||
recipeId,
|
||||
],
|
||||
materials: materials,
|
||||
materials: result.materials,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -2151,6 +2161,47 @@ export const GameProvider = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncNewContent = useCallback(async() => {
|
||||
try {
|
||||
const data = await syncNewContentApi();
|
||||
setState(data.state);
|
||||
if (data.signature !== undefined) {
|
||||
signatureReference.current = data.signature;
|
||||
localStorage.setItem("elysium_save_signature", data.signature);
|
||||
}
|
||||
return {
|
||||
achievementsAdded: data.achievementsAdded,
|
||||
adventurersAdded: data.adventurersAdded,
|
||||
bossRewardsPatched: data.bossRewardsPatched,
|
||||
bossesAdded: data.bossesAdded,
|
||||
equipmentAdded: data.equipmentAdded,
|
||||
explorationAreasAdded: data.explorationAreasAdded,
|
||||
questRewardsPatched: data.questRewardsPatched,
|
||||
questsAdded: data.questsAdded,
|
||||
upgradesAdded: data.upgradesAdded,
|
||||
zonesAdded: data.zonesAdded,
|
||||
};
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Failed to sync new content",
|
||||
);
|
||||
return {
|
||||
achievementsAdded: 0,
|
||||
adventurersAdded: 0,
|
||||
bossRewardsPatched: 0,
|
||||
bossesAdded: 0,
|
||||
equipmentAdded: 0,
|
||||
explorationAreasAdded: 0,
|
||||
questRewardsPatched: 0,
|
||||
questsAdded: 0,
|
||||
upgradesAdded: 0,
|
||||
zonesAdded: 0,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debugHardReset = useCallback(async() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -2251,6 +2302,7 @@ export const GameProvider = ({
|
||||
startQuest,
|
||||
state,
|
||||
syncError,
|
||||
syncNewContent,
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
@@ -2323,6 +2375,7 @@ export const GameProvider = ({
|
||||
startQuest,
|
||||
state,
|
||||
syncError,
|
||||
syncNewContent,
|
||||
toggleAutoAdventurer,
|
||||
toggleAutoBoss,
|
||||
toggleAutoPrestige,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "elysium",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@elysium/types",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./prod/src/index.js",
|
||||
|
||||
@@ -55,6 +55,7 @@ export type {
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
@@ -72,6 +73,7 @@ export type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
|
||||
@@ -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 {
|
||||
@@ -451,6 +457,109 @@ interface ForceUnlocksResponse {
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
interface SyncNewContentResponse {
|
||||
|
||||
/**
|
||||
* The updated game state after injecting all missing content entries.
|
||||
*/
|
||||
state: GameState;
|
||||
|
||||
/**
|
||||
* Number of adventurer tiers added to the save.
|
||||
*/
|
||||
adventurersAdded: number;
|
||||
|
||||
/**
|
||||
* Number of existing adventurer entries whose stats were patched to match current defaults.
|
||||
*/
|
||||
adventurerStatsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of upgrades added to the save.
|
||||
*/
|
||||
upgradesAdded: number;
|
||||
|
||||
/**
|
||||
* Number of rewards patched onto existing quests.
|
||||
*/
|
||||
questRewardsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of quests added to the save.
|
||||
*/
|
||||
questsAdded: number;
|
||||
|
||||
/**
|
||||
* Number of bosses added to the save.
|
||||
*/
|
||||
bossesAdded: number;
|
||||
|
||||
/**
|
||||
* Number of upgrade reward IDs patched onto existing bosses.
|
||||
*/
|
||||
bossRewardsPatched: number;
|
||||
|
||||
/**
|
||||
* Number of equipment items added to the save.
|
||||
*/
|
||||
equipmentAdded: number;
|
||||
|
||||
/**
|
||||
* Number of achievements added to the save.
|
||||
*/
|
||||
achievementsAdded: number;
|
||||
|
||||
/**
|
||||
* Number of zones added to the save.
|
||||
*/
|
||||
zonesAdded: number;
|
||||
|
||||
/**
|
||||
* Number of exploration areas added to the save.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export type {
|
||||
AboutResponse,
|
||||
ApiError,
|
||||
@@ -465,6 +574,7 @@ export type {
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreClaimableResponse,
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
@@ -482,6 +592,7 @@ export type {
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
SyncNewContentResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
|
||||
Reference in New Issue
Block a user