feat: goddess sync, sanitize, and apotheosis init (chunk 3)

- initialState: add initialGoddessState() with all goddess sub-objects
- apotheosis: init GoddessState on first apotheosis, preserve on subsequent
- game: add goddessSpread block in validateAndSanitize (server-only fields capped, forward-only boss/quest/achievement enforcement)
- debug: add injectMissingGoddessExplorationAreas helper and inject all 8 goddess content arrays in syncNewContent
- vitest.config.ts: remove 8 goddess data files from coverage exclude (now imported via initialState)
- tests: full coverage for all new code (482 tests, 100% coverage)
This commit is contained in:
2026-04-13 14:23:02 -07:00
committed by Naomi Carrigan
parent c5d1f53eef
commit 7da1f3942d
9 changed files with 666 additions and 32 deletions
+76
View File
@@ -1128,6 +1128,82 @@ describe("debug route", () => {
expect(body.state.exploration?.craftedClickMultiplier).toBe(1);
expect(body.state.exploration?.craftedCombatMultiplier).toBe(1);
});
it("injects goddess content arrays when state.goddess exists with empty arrays", async () => {
const goddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const state = makeState({ goddess });
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 { goddessAchievementsAdded: number; goddessBossesAdded: number; goddessQuestsAdded: number; goddessZonesAdded: number };
expect(body.goddessAchievementsAdded).toBeGreaterThan(0);
expect(body.goddessBossesAdded).toBeGreaterThan(0);
expect(body.goddessQuestsAdded).toBeGreaterThan(0);
expect(body.goddessZonesAdded).toBeGreaterThan(0);
});
it("returns zero goddess counts when state.goddess 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 { goddessAchievementsAdded: number; goddessBossesAdded: number; goddessDiscipesAdded: number; goddessEquipmentAdded: number; goddessExplorationAreasAdded: number; goddessQuestsAdded: number; goddessUpgradesAdded: number; goddessZonesAdded: number };
expect(body.goddessAchievementsAdded).toBe(0);
expect(body.goddessBossesAdded).toBe(0);
expect(body.goddessDiscipesAdded).toBe(0);
expect(body.goddessEquipmentAdded).toBe(0);
expect(body.goddessExplorationAreasAdded).toBe(0);
expect(body.goddessQuestsAdded).toBe(0);
expect(body.goddessUpgradesAdded).toBe(0);
expect(body.goddessZonesAdded).toBe(0);
});
it("injects goddess exploration areas when goddess has no areas", async () => {
const goddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const state = makeState({ goddess });
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 { goddessExplorationAreasAdded: number };
expect(body.goddessExplorationAreasAdded).toBeGreaterThan(0);
});
});
describe("POST /hard-reset", () => {
+182
View File
@@ -513,6 +513,188 @@ describe("game route", () => {
const res = await save({ state: stateWithCompanion });
expect(res.status).toBe(200);
});
it("passes through goddess when incoming has goddess and previous does not", async () => {
const goddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const prevState = makeState();
const incomingState = makeState({ goddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess).toBeDefined();
});
it("does not add goddess to result when neither previous nor incoming has goddess", async () => {
const prevState = makeState();
const incomingState = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess).toBeUndefined();
});
it("preserves previous goddess when incoming save lacks goddess", async () => {
const goddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const prevState = makeState({ goddess });
const incomingState = makeState();
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess).toEqual(goddess);
});
it("caps goddess totalPrayersEarned at previous value (server-only field)", async () => {
const prevGoddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 100,
upgrades: [],
zones: [],
};
const incomingGoddess: GameState["goddess"] = {
...prevGoddess,
totalPrayersEarned: 9999,
};
const prevState = makeState({ goddess: prevGoddess });
const incomingState = makeState({ goddess: incomingGoddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
expect(savedState.goddess?.totalPrayersEarned).toBe(100);
});
it("restores goddess boss defeated status when incoming tries to un-defeat it", async () => {
const prevGoddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [{ id: "divine_sentinel", status: "defeated", currentHp: 0, maxHp: 5000 }] as GameState["goddess"]["bosses"],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const incomingGoddess: GameState["goddess"] = {
...prevGoddess,
bosses: [{ id: "divine_sentinel", status: "available", currentHp: 5000, maxHp: 5000 }] as GameState["goddess"]["bosses"],
};
const prevState = makeState({ goddess: prevGoddess });
const incomingState = makeState({ goddess: incomingGoddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
const boss = savedState.goddess?.bosses.find((b) => b.id === "divine_sentinel");
expect(boss?.status).toBe("defeated");
});
it("restores goddess quest completed status when incoming tries to un-complete it", async () => {
const prevGoddess: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [{ id: "offering_ritual", status: "completed" }] as GameState["goddess"]["quests"],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const incomingGoddess: GameState["goddess"] = {
...prevGoddess,
quests: [{ id: "offering_ritual", status: "available" }] as GameState["goddess"]["quests"],
};
const prevState = makeState({ goddess: prevGoddess });
const incomingState = makeState({ goddess: incomingGoddess });
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
const res = await save({ state: incomingState });
expect(res.status).toBe(200);
const savedState = (vi.mocked(prisma.gameState.upsert).mock.calls[0][0] as unknown as { create: { state: GameState } }).create.state;
const quest = savedState.goddess?.quests.find((q) => q.id === "offering_ritual");
expect(quest?.status).toBe("completed");
});
});
describe("GET /load error path", () => {
+36
View File
@@ -112,4 +112,40 @@ describe("buildPostApotheosisState", () => {
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.apotheosis?.count).toBe(1);
});
it("initialises goddess state on first apotheosis (count goes to 1)", () => {
const state = makeMinimalState();
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.goddess).toBeDefined();
});
it("preserves existing goddess state on second apotheosis (count goes to 2)", () => {
const goddessState: GameState["goddess"] = {
achievements: [],
baseClickPower: 1,
bosses: [],
consecration: { count: 0, divinity: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
disciples: [],
enlightenment: { count: 0, purchasedUpgradeIds: [], stardust: 0, stardustCombatMultiplier: 1, stardustConsecrationDivinityMultiplier: 1, stardustConsecrationThresholdMultiplier: 1, stardustMetaMultiplier: 1, stardustPrayersMultiplier: 1 },
equipment: [],
exploration: { areas: [], craftedCombatMultiplier: 1, craftedDivinityMultiplier: 1, craftedPrayersMultiplier: 1, craftedRecipeIds: [], materials: [] },
lastTickAt: 0,
lifetimeBossesDefeated: 0,
lifetimePrayersEarned: 0,
lifetimeQuestsCompleted: 0,
quests: [],
totalPrayersEarned: 0,
upgrades: [],
zones: [],
};
const state = makeMinimalState({ apotheosis: { count: 1 }, goddess: goddessState });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.goddess).toEqual(goddessState);
});
it("does not add goddess when count goes to 2 but no goddess exists on current state", () => {
const state = makeMinimalState({ apotheosis: { count: 1 } });
const { updatedState } = buildPostApotheosisState(state, "T");
expect(updatedState.goddess).toBeUndefined();
});
});