generated from nhcarrigan/template
feat: patch all content stats on sync to keep saves up to date
Sync New Content now updates canonical fields on all existing entries to match current defaults: quests (duration, prerequisites, combat requirement), bosses (HP, damage, rewards, prestige requirement), zones (unlock conditions), upgrades (costs, multiplier), equipment (bonus, cost, set), and achievements (condition, reward). Crafting multipliers are also recomputed from craftedRecipeIds so recipe balance changes apply to existing saves.
This commit is contained in:
+267
-11
@@ -20,6 +20,7 @@ import { defaultEquipment } from "../data/equipment.js";
|
|||||||
import { defaultExplorations } from "../data/explorations.js";
|
import { defaultExplorations } from "../data/explorations.js";
|
||||||
import { initialGameState } from "../data/initialState.js";
|
import { initialGameState } from "../data/initialState.js";
|
||||||
import { defaultQuests } from "../data/quests.js";
|
import { defaultQuests } from "../data/quests.js";
|
||||||
|
import { defaultRecipes } from "../data/recipes.js";
|
||||||
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
import { currentSchemaVersion } from "../data/schemaVersion.js";
|
||||||
import { defaultUpgrades } from "../data/upgrades.js";
|
import { defaultUpgrades } from "../data/upgrades.js";
|
||||||
import { defaultZones } from "../data/zones.js";
|
import { defaultZones } from "../data/zones.js";
|
||||||
@@ -653,6 +654,226 @@ const patchAdventurerStats = (state: GameState): number => {
|
|||||||
return patched;
|
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 */
|
/* 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
|
* Syncs a player's save with the current game data, injecting any content
|
||||||
@@ -664,19 +885,33 @@ const patchAdventurerStats = (state: GameState): number => {
|
|||||||
const syncNewContent = (
|
const syncNewContent = (
|
||||||
state: GameState,
|
state: GameState,
|
||||||
): {
|
): {
|
||||||
achievementsAdded: number;
|
achievementsAdded: number;
|
||||||
adventurersAdded: number;
|
achievementsPatched: number;
|
||||||
adventurerStatsPatched: number;
|
adventurersAdded: number;
|
||||||
bossesAdded: number;
|
adventurerStatsPatched: number;
|
||||||
bossRewardsPatched: number;
|
bossesAdded: number;
|
||||||
equipmentAdded: number;
|
bossesPatched: number;
|
||||||
explorationAreasAdded: number;
|
bossRewardsPatched: number;
|
||||||
questRewardsPatched: number;
|
craftingRecipesReapplied: number;
|
||||||
questsAdded: number;
|
equipmentAdded: number;
|
||||||
upgradesAdded: number;
|
equipmentPatched: number;
|
||||||
zonesAdded: number;
|
explorationAreasAdded: number;
|
||||||
|
questRewardsPatched: number;
|
||||||
|
questsAdded: number;
|
||||||
|
questsPatched: number;
|
||||||
|
upgradesAdded: number;
|
||||||
|
upgradesPatched: number;
|
||||||
|
zonesAdded: number;
|
||||||
|
zonesPatched: number;
|
||||||
} => {
|
} => {
|
||||||
const adventurerStatsPatched = patchAdventurerStats(state);
|
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 achievementsAdded = injectMissingEntries(state.achievements, defaultAchievements);
|
||||||
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
|
const adventurersAdded = injectMissingEntries(state.adventurers, defaultAdventurers);
|
||||||
const bossRewardsPatched = patchBossUpgradeRewards(state);
|
const bossRewardsPatched = patchBossUpgradeRewards(state);
|
||||||
@@ -689,16 +924,23 @@ const syncNewContent = (
|
|||||||
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
|
const zonesAdded = injectMissingEntries(state.zones, defaultZones);
|
||||||
return {
|
return {
|
||||||
achievementsAdded,
|
achievementsAdded,
|
||||||
|
achievementsPatched,
|
||||||
adventurerStatsPatched,
|
adventurerStatsPatched,
|
||||||
adventurersAdded,
|
adventurersAdded,
|
||||||
bossRewardsPatched,
|
bossRewardsPatched,
|
||||||
bossesAdded,
|
bossesAdded,
|
||||||
|
bossesPatched,
|
||||||
|
craftingRecipesReapplied,
|
||||||
equipmentAdded,
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
explorationAreasAdded,
|
explorationAreasAdded,
|
||||||
questRewardsPatched,
|
questRewardsPatched,
|
||||||
questsAdded,
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
upgradesAdded,
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
zonesAdded,
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||||
@@ -783,16 +1025,23 @@ debugRouter.post("/sync-new-content", async(context) => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
achievementsAdded,
|
achievementsAdded,
|
||||||
|
achievementsPatched,
|
||||||
adventurersAdded,
|
adventurersAdded,
|
||||||
adventurerStatsPatched,
|
adventurerStatsPatched,
|
||||||
bossesAdded,
|
bossesAdded,
|
||||||
|
bossesPatched,
|
||||||
bossRewardsPatched,
|
bossRewardsPatched,
|
||||||
|
craftingRecipesReapplied,
|
||||||
equipmentAdded,
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
explorationAreasAdded,
|
explorationAreasAdded,
|
||||||
questRewardsPatched,
|
questRewardsPatched,
|
||||||
questsAdded,
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
upgradesAdded,
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
zonesAdded,
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
} = syncNewContent(state);
|
} = syncNewContent(state);
|
||||||
|
|
||||||
const updatedAt = Date.now();
|
const updatedAt = Date.now();
|
||||||
@@ -810,18 +1059,25 @@ debugRouter.post("/sync-new-content", async(context) => {
|
|||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
achievementsAdded,
|
achievementsAdded,
|
||||||
|
achievementsPatched,
|
||||||
adventurerStatsPatched,
|
adventurerStatsPatched,
|
||||||
adventurersAdded,
|
adventurersAdded,
|
||||||
bossRewardsPatched,
|
bossRewardsPatched,
|
||||||
bossesAdded,
|
bossesAdded,
|
||||||
|
bossesPatched,
|
||||||
|
craftingRecipesReapplied,
|
||||||
equipmentAdded,
|
equipmentAdded,
|
||||||
|
equipmentPatched,
|
||||||
explorationAreasAdded,
|
explorationAreasAdded,
|
||||||
questRewardsPatched,
|
questRewardsPatched,
|
||||||
questsAdded,
|
questsAdded,
|
||||||
|
questsPatched,
|
||||||
signature,
|
signature,
|
||||||
state,
|
state,
|
||||||
upgradesAdded,
|
upgradesAdded,
|
||||||
|
upgradesPatched,
|
||||||
zonesAdded,
|
zonesAdded,
|
||||||
|
zonesPatched,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
void logger.error(
|
void logger.error(
|
||||||
|
|||||||
@@ -750,6 +750,32 @@ describe("debug route", () => {
|
|||||||
expect(res.status).toBe(200);
|
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 () => {
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
@@ -773,6 +799,215 @@ describe("debug route", () => {
|
|||||||
const res = await syncNewContent();
|
const res = await syncNewContent();
|
||||||
expect(res.status).toBe(500);
|
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", () => {
|
describe("POST /hard-reset", () => {
|
||||||
|
|||||||
@@ -13,17 +13,24 @@ import { ConfirmationModal } from "../ui/confirmationModal.js";
|
|||||||
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
type ActiveModal = "force-unlocks" | "hard-reset" | "sync-new-content" | null;
|
||||||
|
|
||||||
interface SyncNewContentResult {
|
interface SyncNewContentResult {
|
||||||
achievementsAdded: number | undefined;
|
achievementsAdded: number | undefined;
|
||||||
adventurersAdded: number | undefined;
|
achievementsPatched: number | undefined;
|
||||||
adventurerStatsPatched: number | undefined;
|
adventurersAdded: number | undefined;
|
||||||
bossesAdded: number | undefined;
|
adventurerStatsPatched: number | undefined;
|
||||||
bossRewardsPatched: number | undefined;
|
bossesAdded: number | undefined;
|
||||||
equipmentAdded: number | undefined;
|
bossesPatched: number | undefined;
|
||||||
explorationAreasAdded: number | undefined;
|
bossRewardsPatched: number | undefined;
|
||||||
questRewardsPatched: number | undefined;
|
craftingRecipesReapplied: number | undefined;
|
||||||
questsAdded: number | undefined;
|
equipmentAdded: number | undefined;
|
||||||
upgradesAdded: number | undefined;
|
equipmentPatched: number | undefined;
|
||||||
zonesAdded: 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 => {
|
const safeNumber = (value: number | undefined): number => {
|
||||||
@@ -48,6 +55,13 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
|||||||
[ safeNumber(result.upgradesAdded), "upgrade(s)" ],
|
[ safeNumber(result.upgradesAdded), "upgrade(s)" ],
|
||||||
[ safeNumber(result.equipmentAdded), "equipment item(s)" ],
|
[ safeNumber(result.equipmentAdded), "equipment item(s)" ],
|
||||||
[ safeNumber(result.achievementsAdded), "achievement(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.
|
const parts = entries.
|
||||||
filter(([ count ]) => {
|
filter(([ count ]) => {
|
||||||
|
|||||||
@@ -519,6 +519,41 @@ interface SyncNewContentResponse {
|
|||||||
*/
|
*/
|
||||||
explorationAreasAdded: number;
|
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.
|
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user