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:
2026-04-16 17:46:59 -07:00
committed by Naomi Carrigan
parent e02827dbb6
commit d45b80fe4a
3 changed files with 390 additions and 1 deletions
+158 -1
View File
@@ -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");
+192
View File
@@ -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" }));
+40
View File
@@ -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.
*/