generated from nhcarrigan/template
feat: sync and patch all content stats on existing saves #130
@@ -625,38 +625,80 @@ const patchBossUpgradeRewards = (state: GameState): number => {
|
|||||||
return added;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
/* 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
|
||||||
* entries that are missing because they were added after the save was created.
|
* entries that are missing because they were added after the save was created,
|
||||||
|
* and patching stat fields on existing entries to match the current defaults.
|
||||||
* @param state - The player's current game state (mutated in place).
|
* @param state - The player's current game state (mutated in place).
|
||||||
* @returns Counts of how many entries were added per content type.
|
* @returns Counts of how many entries were added or patched per content type.
|
||||||
*/
|
*/
|
||||||
const syncNewContent = (
|
const syncNewContent = (
|
||||||
state: GameState,
|
state: GameState,
|
||||||
): {
|
): {
|
||||||
achievementsAdded: number;
|
achievementsAdded: number;
|
||||||
adventurersAdded: number;
|
adventurersAdded: number;
|
||||||
bossesAdded: number;
|
adventurerStatsPatched: number;
|
||||||
bossRewardsPatched: number;
|
bossesAdded: number;
|
||||||
equipmentAdded: number;
|
bossRewardsPatched: number;
|
||||||
explorationAreasAdded: number;
|
equipmentAdded: number;
|
||||||
questRewardsPatched: number;
|
explorationAreasAdded: number;
|
||||||
questsAdded: number;
|
questRewardsPatched: number;
|
||||||
upgradesAdded: number;
|
questsAdded: number;
|
||||||
zonesAdded: number;
|
upgradesAdded: number;
|
||||||
|
zonesAdded: number;
|
||||||
} => {
|
} => {
|
||||||
|
const adventurerStatsPatched = patchAdventurerStats(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 {
|
return {
|
||||||
achievementsAdded: injectMissingEntries(state.achievements, defaultAchievements),
|
achievementsAdded,
|
||||||
adventurersAdded: injectMissingEntries(state.adventurers, defaultAdventurers),
|
adventurerStatsPatched,
|
||||||
bossRewardsPatched: patchBossUpgradeRewards(state),
|
adventurersAdded,
|
||||||
bossesAdded: injectMissingEntries(state.bosses, defaultBosses),
|
bossRewardsPatched,
|
||||||
equipmentAdded: injectMissingEntries(state.equipment, defaultEquipment),
|
bossesAdded,
|
||||||
explorationAreasAdded: injectMissingExplorationAreas(state),
|
equipmentAdded,
|
||||||
questRewardsPatched: patchQuestRewards(state),
|
explorationAreasAdded,
|
||||||
questsAdded: injectMissingEntries(state.quests, defaultQuests),
|
questRewardsPatched,
|
||||||
upgradesAdded: injectMissingEntries(state.upgrades, defaultUpgrades),
|
questsAdded,
|
||||||
zonesAdded: injectMissingEntries(state.zones, defaultZones),
|
upgradesAdded,
|
||||||
|
zonesAdded,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
/* eslint-enable stylistic/max-len -- Re-enable after long lines */
|
||||||
@@ -742,9 +784,12 @@ debugRouter.post("/sync-new-content", async(context) => {
|
|||||||
const {
|
const {
|
||||||
achievementsAdded,
|
achievementsAdded,
|
||||||
adventurersAdded,
|
adventurersAdded,
|
||||||
|
adventurerStatsPatched,
|
||||||
bossesAdded,
|
bossesAdded,
|
||||||
|
bossRewardsPatched,
|
||||||
equipmentAdded,
|
equipmentAdded,
|
||||||
explorationAreasAdded,
|
explorationAreasAdded,
|
||||||
|
questRewardsPatched,
|
||||||
questsAdded,
|
questsAdded,
|
||||||
upgradesAdded,
|
upgradesAdded,
|
||||||
zonesAdded,
|
zonesAdded,
|
||||||
@@ -765,10 +810,13 @@ debugRouter.post("/sync-new-content", async(context) => {
|
|||||||
|
|
||||||
return context.json({
|
return context.json({
|
||||||
achievementsAdded,
|
achievementsAdded,
|
||||||
|
adventurerStatsPatched,
|
||||||
adventurersAdded,
|
adventurersAdded,
|
||||||
|
bossRewardsPatched,
|
||||||
bossesAdded,
|
bossesAdded,
|
||||||
equipmentAdded,
|
equipmentAdded,
|
||||||
explorationAreasAdded,
|
explorationAreasAdded,
|
||||||
|
questRewardsPatched,
|
||||||
questsAdded,
|
questsAdded,
|
||||||
signature,
|
signature,
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -567,12 +567,44 @@ describe("debug route", () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 with zero counts when state already has all content", async () => {
|
it("returns 200 with zero added counts when state already has all content", async () => {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
const res = await syncNewContent();
|
const res = await syncNewContent();
|
||||||
expect(res.status).toBe(200);
|
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 () => {
|
it("injects missing entries when arrays are empty", async () => {
|
||||||
|
|||||||
@@ -13,16 +13,17 @@ 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;
|
adventurersAdded: number | undefined;
|
||||||
bossesAdded: number | undefined;
|
adventurerStatsPatched: number | undefined;
|
||||||
bossRewardsPatched: number | undefined;
|
bossesAdded: number | undefined;
|
||||||
equipmentAdded: number | undefined;
|
bossRewardsPatched: number | undefined;
|
||||||
explorationAreasAdded: number | undefined;
|
equipmentAdded: number | undefined;
|
||||||
questRewardsPatched: number | undefined;
|
explorationAreasAdded: number | undefined;
|
||||||
questsAdded: number | undefined;
|
questRewardsPatched: number | undefined;
|
||||||
upgradesAdded: number | undefined;
|
questsAdded: number | undefined;
|
||||||
zonesAdded: number | undefined;
|
upgradesAdded: number | undefined;
|
||||||
|
zonesAdded: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeNumber = (value: number | undefined): number => {
|
const safeNumber = (value: number | undefined): number => {
|
||||||
@@ -43,6 +44,7 @@ const buildSyncNewContentMessage = (result: SyncNewContentResult): string => {
|
|||||||
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
|
[ safeNumber(result.bossRewardsPatched), "boss reward(s) patched" ],
|
||||||
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
|
[ safeNumber(result.explorationAreasAdded), "exploration area(s)" ],
|
||||||
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
|
[ safeNumber(result.adventurersAdded), "adventurer tier(s)" ],
|
||||||
|
[ safeNumber(result.adventurerStatsPatched), "adventurer stat(s) patched" ],
|
||||||
[ 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)" ],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @license Naomi's Public License
|
* @license Naomi's Public License
|
||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable max-lines -- API types file grows with each new endpoint */
|
||||||
import type {
|
import type {
|
||||||
EquipmentBonus,
|
EquipmentBonus,
|
||||||
EquipmentRarity,
|
EquipmentRarity,
|
||||||
@@ -468,6 +469,11 @@ interface SyncNewContentResponse {
|
|||||||
*/
|
*/
|
||||||
adventurersAdded: number;
|
adventurersAdded: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of existing adventurer entries whose stats were patched to match current defaults.
|
||||||
|
*/
|
||||||
|
adventurerStatsPatched: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of upgrades added to the save.
|
* Number of upgrades added to the save.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user