From d45b80fe4a77787bb0bd060ac37bbf2626d495ef Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 16 Apr 2026 17:46:59 -0700 Subject: [PATCH] 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 --- apps/api/src/routes/debug.ts | 159 +++++++++++++++++++++- apps/api/test/routes/debug.spec.ts | 192 +++++++++++++++++++++++++++ packages/types/src/interfaces/api.ts | 40 ++++++ 3 files changed, 390 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts index 61e63b4..07e01c1 100644 --- a/apps/api/src/routes/debug.ts +++ b/apps/api/src/routes/debug.ts @@ -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"); diff --git a/apps/api/test/routes/debug.spec.ts b/apps/api/test/routes/debug.spec.ts index 1293916..82c5e87 100644 --- a/apps/api/test/routes/debug.spec.ts +++ b/apps/api/test/routes/debug.spec.ts @@ -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 => ({ + 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 = { + 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 = { + 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 = { + 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" })); diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index eb57d76..b4d8b30 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -604,6 +604,46 @@ interface SyncNewContentResponse { */ goddessZonesAdded: number; + /** + * Number of vampire achievements added to the save. + */ + vampireAchievementsAdded: number; + + /** + * Number of vampire bosses added to the save. + */ + vampireBossesAdded: number; + + /** + * Number of vampire thralls added to the save. + */ + vampireThrallsAdded: number; + + /** + * Number of vampire equipment items added to the save. + */ + vampireEquipmentAdded: number; + + /** + * Number of vampire exploration areas added to the save. + */ + vampireExplorationAreasAdded: number; + + /** + * Number of vampire quests added to the save. + */ + vampireQuestsAdded: number; + + /** + * Number of vampire upgrades added to the save. + */ + vampireUpgradesAdded: number; + + /** + * Number of vampire zones added to the save. + */ + vampireZonesAdded: number; + /** * HMAC-SHA256 signature of the updated state for anti-cheat chain continuity. */