generated from nhcarrigan/template
feat: vampire syncNewContent injection and grant-eternal-sovereignty debug endpoint
Adds vampire parity with existing goddess debug tooling: - `SyncNewContentResponse` type gains vampire count fields - `syncNewContent` injects missing vampire content arrays for players who have entered the vampire realm (achievements, bosses, equipment, exploration areas, quests, thralls, upgrades, zones) - `/grant-eternal-sovereignty` debug endpoint mirrors `/grant-apotheosis`, setting `vampire.eternalSovereignty.count = 1` for saves that need it - Full test coverage for all new paths in debug.spec.ts
This commit is contained in:
@@ -26,11 +26,23 @@ import { defaultGoddessExplorationAreas } from "../data/goddessExplorations.js";
|
||||
import { defaultGoddessQuests } from "../data/goddessQuests.js";
|
||||
import { defaultGoddessUpgrades } from "../data/goddessUpgrades.js";
|
||||
import { defaultGoddessZones } from "../data/goddessZones.js";
|
||||
import { initialGameState, initialGoddessState } from "../data/initialState.js";
|
||||
import {
|
||||
initialGameState,
|
||||
initialGoddessState,
|
||||
initialVampireState,
|
||||
} 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 { defaultVampireAchievements } from "../data/vampireAchievements.js";
|
||||
import { defaultVampireBosses } from "../data/vampireBosses.js";
|
||||
import { defaultVampireEquipment } from "../data/vampireEquipment.js";
|
||||
import { defaultVampireExplorationAreas } from "../data/vampireExplorations.js";
|
||||
import { defaultVampireQuests } from "../data/vampireQuests.js";
|
||||
import { defaultVampireThralls } from "../data/vampireThralls.js";
|
||||
import { defaultVampireUpgrades } from "../data/vampireUpgrades.js";
|
||||
import { defaultVampireZones } from "../data/vampireZones.js";
|
||||
import { defaultZones } from "../data/zones.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
@@ -602,6 +614,33 @@ const injectMissingGoddessExplorationAreas = (state: GameState): number => {
|
||||
return added;
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects any vampire exploration areas from the defaults that are missing from
|
||||
* the player's vampire exploration state, seeding each new area as locked.
|
||||
* @param state - The player's current game state (mutated in place).
|
||||
* @returns The number of vampire exploration areas that were added.
|
||||
*/
|
||||
const injectMissingVampireExplorationAreas = (state: GameState): number => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (state.vampire === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const existingIds = new Set(state.vampire.exploration.areas.map((area) => {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return area.id;
|
||||
}));
|
||||
let added = 0;
|
||||
for (const area of defaultVampireExplorationAreas) {
|
||||
if (!existingIds.has(area.id)) {
|
||||
state.vampire.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).
|
||||
@@ -1030,6 +1069,14 @@ const syncNewContent = (
|
||||
questsPatched: number;
|
||||
upgradesAdded: number;
|
||||
upgradesPatched: number;
|
||||
vampireAchievementsAdded: number;
|
||||
vampireBossesAdded: number;
|
||||
vampireEquipmentAdded: number;
|
||||
vampireExplorationAreasAdded: number;
|
||||
vampireQuestsAdded: number;
|
||||
vampireThrallsAdded: number;
|
||||
vampireUpgradesAdded: number;
|
||||
vampireZonesAdded: number;
|
||||
zonesAdded: number;
|
||||
zonesPatched: number;
|
||||
} => {
|
||||
@@ -1079,6 +1126,33 @@ const syncNewContent = (
|
||||
= injectMissingEntries(state.goddess.zones, defaultGoddessZones);
|
||||
}
|
||||
|
||||
// Inject missing vampire content for players who have entered the Vampire realm
|
||||
let vampireAchievementsAdded = 0;
|
||||
let vampireBossesAdded = 0;
|
||||
let vampireEquipmentAdded = 0;
|
||||
let vampireExplorationAreasAdded = 0;
|
||||
let vampireQuestsAdded = 0;
|
||||
let vampireThrallsAdded = 0;
|
||||
let vampireUpgradesAdded = 0;
|
||||
let vampireZonesAdded = 0;
|
||||
if (state.vampire) {
|
||||
vampireAchievementsAdded
|
||||
= injectMissingEntries(state.vampire.achievements, defaultVampireAchievements);
|
||||
vampireBossesAdded
|
||||
= injectMissingEntries(state.vampire.bosses, defaultVampireBosses);
|
||||
vampireEquipmentAdded
|
||||
= injectMissingEntries(state.vampire.equipment, defaultVampireEquipment);
|
||||
vampireExplorationAreasAdded = injectMissingVampireExplorationAreas(state);
|
||||
vampireQuestsAdded
|
||||
= injectMissingEntries(state.vampire.quests, defaultVampireQuests);
|
||||
vampireThrallsAdded
|
||||
= injectMissingEntries(state.vampire.thralls, defaultVampireThralls);
|
||||
vampireUpgradesAdded
|
||||
= injectMissingEntries(state.vampire.upgrades, defaultVampireUpgrades);
|
||||
vampireZonesAdded
|
||||
= injectMissingEntries(state.vampire.zones, defaultVampireZones);
|
||||
}
|
||||
|
||||
return {
|
||||
achievementsAdded,
|
||||
achievementsPatched,
|
||||
@@ -1104,6 +1178,14 @@ const syncNewContent = (
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
vampireAchievementsAdded,
|
||||
vampireBossesAdded,
|
||||
vampireEquipmentAdded,
|
||||
vampireExplorationAreasAdded,
|
||||
vampireQuestsAdded,
|
||||
vampireThrallsAdded,
|
||||
vampireUpgradesAdded,
|
||||
vampireZonesAdded,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
};
|
||||
@@ -1213,6 +1295,14 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
questsPatched,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
vampireAchievementsAdded,
|
||||
vampireBossesAdded,
|
||||
vampireEquipmentAdded,
|
||||
vampireExplorationAreasAdded,
|
||||
vampireQuestsAdded,
|
||||
vampireThrallsAdded,
|
||||
vampireUpgradesAdded,
|
||||
vampireZonesAdded,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
} = syncNewContent(state);
|
||||
@@ -1257,6 +1347,14 @@ debugRouter.post("/sync-new-content", async(context) => {
|
||||
state,
|
||||
upgradesAdded,
|
||||
upgradesPatched,
|
||||
vampireAchievementsAdded,
|
||||
vampireBossesAdded,
|
||||
vampireEquipmentAdded,
|
||||
vampireExplorationAreasAdded,
|
||||
vampireQuestsAdded,
|
||||
vampireThrallsAdded,
|
||||
vampireUpgradesAdded,
|
||||
vampireZonesAdded,
|
||||
zonesAdded,
|
||||
zonesPatched,
|
||||
});
|
||||
@@ -1329,6 +1427,65 @@ debugRouter.post("/grant-apotheosis", async(context) => {
|
||||
}
|
||||
});
|
||||
|
||||
debugRouter.post("/grant-eternal-sovereignty", async(context) => {
|
||||
try {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; double-cast required */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
const updatedState: GameState
|
||||
= (state.vampire?.eternalSovereignty.count ?? 0) >= 1
|
||||
? state
|
||||
: {
|
||||
...state,
|
||||
vampire: state.vampire
|
||||
? { ...state.vampire, eternalSovereignty: { count: 1 } }
|
||||
: { ...initialVampireState(), eternalSovereignty: { count: 1 } },
|
||||
};
|
||||
|
||||
if (updatedState !== state) {
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
}
|
||||
|
||||
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||
const signature
|
||||
= secret === undefined
|
||||
? undefined
|
||||
: computeHmac(JSON.stringify(updatedState), secret);
|
||||
|
||||
return context.json({
|
||||
currentSchemaVersion: currentSchemaVersion,
|
||||
loginBonus: null,
|
||||
loginStreak: 0,
|
||||
offlineEssence: 0,
|
||||
offlineGold: 0,
|
||||
offlineSeconds: 0,
|
||||
schemaOutdated: false,
|
||||
signature: signature,
|
||||
state: updatedState,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"debug_grant_eternal_sovereignty",
|
||||
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");
|
||||
|
||||
@@ -1206,6 +1206,198 @@ describe("debug route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /sync-new-content — vampire injection", () => {
|
||||
const syncNewContent = () =>
|
||||
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
|
||||
|
||||
const makeVampireState = (): NonNullable<GameState["vampire"]> => ({
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
});
|
||||
|
||||
it("injects vampire content arrays when state.vampire exists with empty arrays", async () => {
|
||||
const state = makeState({ vampire: makeVampireState() });
|
||||
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 { vampireAchievementsAdded: number; vampireBossesAdded: number; vampireQuestsAdded: number; vampireZonesAdded: number };
|
||||
expect(body.vampireAchievementsAdded).toBeGreaterThan(0);
|
||||
expect(body.vampireBossesAdded).toBeGreaterThan(0);
|
||||
expect(body.vampireQuestsAdded).toBeGreaterThan(0);
|
||||
expect(body.vampireZonesAdded).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns zero vampire counts when state.vampire is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await syncNewContent();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { vampireAchievementsAdded: number; vampireBossesAdded: number; vampireEquipmentAdded: number; vampireExplorationAreasAdded: number; vampireQuestsAdded: number; vampireThrallsAdded: number; vampireUpgradesAdded: number; vampireZonesAdded: number };
|
||||
expect(body.vampireAchievementsAdded).toBe(0);
|
||||
expect(body.vampireBossesAdded).toBe(0);
|
||||
expect(body.vampireEquipmentAdded).toBe(0);
|
||||
expect(body.vampireExplorationAreasAdded).toBe(0);
|
||||
expect(body.vampireQuestsAdded).toBe(0);
|
||||
expect(body.vampireThrallsAdded).toBe(0);
|
||||
expect(body.vampireUpgradesAdded).toBe(0);
|
||||
expect(body.vampireZonesAdded).toBe(0);
|
||||
});
|
||||
|
||||
it("injects vampire exploration areas when vampire has no areas", async () => {
|
||||
const state = makeState({ vampire: makeVampireState() });
|
||||
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 { vampireExplorationAreasAdded: number };
|
||||
expect(body.vampireExplorationAreasAdded).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /grant-eternal-sovereignty", () => {
|
||||
const grantEternalSovereignty = () =>
|
||||
app.fetch(new Request("http://localhost/debug/grant-eternal-sovereignty", { method: "POST" }));
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("No save found");
|
||||
});
|
||||
|
||||
it("returns 200 with unchanged state when eternalSovereignty count is already >= 1", async () => {
|
||||
const vampire: NonNullable<GameState["vampire"]> = {
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 1 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.vampire?.eternalSovereignty.count).toBe(1);
|
||||
expect(vi.mocked(prisma.gameState.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 and grants eternal sovereignty when not yet granted", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.vampire?.eternalSovereignty.count).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 200 and sets eternalSovereignty when vampire exists with count 0", async () => {
|
||||
const vampire: NonNullable<GameState["vampire"]> = {
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 0 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { state: GameState };
|
||||
expect(body.state.vampire?.eternalSovereignty.count).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 200 with HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
||||
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||
const vampire: NonNullable<GameState["vampire"]> = {
|
||||
achievements: [],
|
||||
awakening: { count: 0, purchasedUpgradeIds: [], soulShards: 0, soulShardsBloodMultiplier: 1, soulShardsCombatMultiplier: 1, soulShardsMetaMultiplier: 1, soulShardsSiringIchorMultiplier: 1, soulShardsSiringThresholdMultiplier: 1 },
|
||||
baseClickPower: 1,
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
eternalSovereignty: { count: 1 },
|
||||
exploration: { areas: [], craftedBloodMultiplier: 1, craftedCombatMultiplier: 1, craftedIchorMultiplier: 1, craftedRecipeIds: [], materials: [] },
|
||||
lastTickAt: 0,
|
||||
lifetimeBloodEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
quests: [],
|
||||
siring: { count: 0, ichor: 0, ichorBloodMultiplier: 1, ichorCombatMultiplier: 1, ichorThrallsMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
thralls: [],
|
||||
totalBloodEarned: 0,
|
||||
upgrades: [],
|
||||
zones: [],
|
||||
};
|
||||
const state = makeState({ vampire });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await grantEternalSovereignty();
|
||||
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 grantEternalSovereignty();
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
|
||||
it("returns 500 when DB throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
||||
const res = await grantEternalSovereignty();
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toContain("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /grant-apotheosis", () => {
|
||||
const grantApotheosis = () =>
|
||||
app.fetch(new Request("http://localhost/debug/grant-apotheosis", { method: "POST" }));
|
||||
|
||||
Reference in New Issue
Block a user