generated from nhcarrigan/template
b3913cef52
Sync New Content now updates baseCost, class, combatPower, essencePerSecond, goldPerSecond, level, and name for all existing adventurer entries to match the current defaults, while preserving count and unlocked state. Closes #126
824 lines
39 KiB
TypeScript
824 lines
39 KiB
TypeScript
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
|
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
|
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Hono } from "hono";
|
|
import type { GameState } from "@elysium/types";
|
|
|
|
vi.mock("../../src/db/client.js", () => ({
|
|
prisma: {
|
|
gameState: { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn() },
|
|
player: { findUnique: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/middleware/auth.js", () => ({
|
|
authMiddleware: vi.fn(async (c: { set: (key: string, value: string) => void }, next: () => Promise<void>) => {
|
|
c.set("discordId", "test_discord_id");
|
|
await next();
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../src/services/logger.js", () => ({
|
|
logger: {
|
|
error: vi.fn().mockResolvedValue(undefined),
|
|
log: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
}));
|
|
|
|
const DISCORD_ID = "test_discord_id";
|
|
|
|
const makeExploration = (areas: GameState["exploration"]["areas"] = []): GameState["exploration"] => ({
|
|
areas: areas,
|
|
craftedCombatMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedGoldMultiplier: 1,
|
|
craftedRecipeIds: [],
|
|
materials: [],
|
|
});
|
|
|
|
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
|
achievements: [],
|
|
adventurers: [],
|
|
baseClickPower: 1,
|
|
bosses: [],
|
|
companions: { activeCompanionId: null, unlockedCompanionIds: [] },
|
|
equipment: [],
|
|
exploration: makeExploration(),
|
|
lastTickAt: 0,
|
|
player: { avatar: null, characterName: "T", discordId: DISCORD_ID, discriminator: "0", totalClicks: 0, totalGoldEarned: 0, username: "u" },
|
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
|
quests: [],
|
|
resources: { crystals: 0, essence: 0, gold: 0, runestones: 0 },
|
|
schemaVersion: 1,
|
|
upgrades: [],
|
|
zones: [],
|
|
...overrides,
|
|
} as GameState);
|
|
|
|
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
|
avatar: null,
|
|
characterName: "TestChar",
|
|
createdAt: 0,
|
|
discordId: DISCORD_ID,
|
|
discriminator: "0",
|
|
lifetimeAchievementsUnlocked: 0,
|
|
lifetimeAdventurersRecruited: 0,
|
|
lifetimeBossesDefeated: 0,
|
|
lifetimeClicks: 0,
|
|
lifetimeGoldEarned: 0,
|
|
lifetimeQuestsCompleted: 0,
|
|
loginStreak: 1,
|
|
username: "test_user",
|
|
...overrides,
|
|
});
|
|
|
|
describe("debug route", () => {
|
|
let app: Hono;
|
|
let prisma: {
|
|
gameState: {
|
|
findUnique: ReturnType<typeof vi.fn>;
|
|
update: ReturnType<typeof vi.fn>;
|
|
upsert: ReturnType<typeof vi.fn>;
|
|
};
|
|
player: { findUnique: ReturnType<typeof vi.fn> };
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const { debugRouter } = await import("../../src/routes/debug.js");
|
|
const { prisma: p } = await import("../../src/db/client.js");
|
|
prisma = p as typeof prisma;
|
|
app = new Hono();
|
|
app.route("/debug", debugRouter);
|
|
});
|
|
|
|
const forceUnlocks = () =>
|
|
app.fetch(new Request("http://localhost/debug/force-unlocks", { method: "POST" }));
|
|
|
|
const hardReset = () =>
|
|
app.fetch(new Request("http://localhost/debug/hard-reset", { method: "POST" }));
|
|
|
|
describe("POST /force-unlocks", () => {
|
|
it("returns 404 when no game state found", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await forceUnlocks();
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 200 with all zeros when no stale locks exist", async () => {
|
|
const state = makeState({
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as {
|
|
bossesUnlocked: number;
|
|
explorationUnlocked: number;
|
|
questsUnlocked: number;
|
|
zonesUnlocked: number;
|
|
};
|
|
expect(body.zonesUnlocked).toBe(0);
|
|
expect(body.explorationUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks verdant_vale when it is locked and has no requirements", async () => {
|
|
const state = makeState({
|
|
zones: [{ id: "verdant_vale", status: "locked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { zonesUnlocked: number };
|
|
expect(body.zonesUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not unlock zone when boss condition is not met", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"],
|
|
quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"],
|
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { zonesUnlocked: number };
|
|
expect(body.zonesUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock zone when quest condition is not met", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"],
|
|
quests: [{ id: "ancient_ruins", status: "active" }] as GameState["quests"],
|
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { zonesUnlocked: number };
|
|
expect(body.zonesUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks zone when both boss and quest conditions are met", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "forest_giant", status: "defeated" }] as GameState["bosses"],
|
|
quests: [{ id: "ancient_ruins", status: "completed" }] as GameState["quests"],
|
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { zonesUnlocked: number };
|
|
expect(body.zonesUnlocked).toBe(1);
|
|
});
|
|
|
|
it("unlocks a quest when zone is unlocked and prerequisites are met", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"],
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { questsUnlocked: number };
|
|
expect(body.questsUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not unlock quest when zone is locked", async () => {
|
|
/*
|
|
* Use shattered_ruins (requires forest_giant defeated) so applyZoneUnlocks
|
|
* cannot auto-unlock it, keeping it locked when applyQuestUnlocks runs.
|
|
*/
|
|
const state = makeState({
|
|
bosses: [{ id: "forest_giant", status: "available" }] as GameState["bosses"],
|
|
quests: [{ id: "necromancer_tower", status: "locked" }] as GameState["quests"],
|
|
zones: [{ id: "shattered_ruins", status: "locked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { questsUnlocked: number };
|
|
expect(body.questsUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock quest when zone is not in state", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "first_steps", status: "locked" }] as GameState["quests"],
|
|
zones: [] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { questsUnlocked: number };
|
|
expect(body.questsUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock quest when it is already available", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "first_steps", status: "available" }] as GameState["quests"],
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { questsUnlocked: number };
|
|
expect(body.questsUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock quest when prerequisites are not completed", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "goblin_camp", status: "locked" }] as GameState["quests"],
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { questsUnlocked: number };
|
|
expect(body.questsUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks the first boss in a zone when the zone is unlocked", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "troll_king", status: "locked" }] as GameState["bosses"],
|
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { bossesUnlocked: number };
|
|
expect(body.bossesUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not unlock boss when prestige requirement is not met", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "the_first_light", status: "locked" }] as GameState["bosses"],
|
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
|
zones: [{ id: "celestial_reaches", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { bossesUnlocked: number };
|
|
expect(body.bossesUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock boss when previous boss is not defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [
|
|
{ id: "troll_king", status: "available" },
|
|
{ id: "lich_queen", status: "locked" },
|
|
] as GameState["bosses"],
|
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { bossesUnlocked: number };
|
|
expect(body.bossesUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock boss when previous boss is not in state", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "lich_queen", status: "locked" }] as GameState["bosses"],
|
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { bossesUnlocked: number };
|
|
expect(body.bossesUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks next boss when previous boss is defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [
|
|
{ id: "troll_king", status: "defeated" },
|
|
{ id: "lich_queen", status: "locked" },
|
|
] as GameState["bosses"],
|
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 0 },
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { bossesUnlocked: number };
|
|
expect(body.bossesUnlocked).toBe(1);
|
|
});
|
|
|
|
it("returns explorationUnlocked=0 when exploration is undefined", async () => {
|
|
const state = makeState({
|
|
exploration: undefined as unknown as GameState["exploration"],
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { explorationUnlocked: number };
|
|
expect(body.explorationUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks exploration area when its zone is unlocked", async () => {
|
|
const state = makeState({
|
|
exploration: makeExploration([
|
|
{ id: "verdant_meadow", status: "locked" } as GameState["exploration"]["areas"][0],
|
|
]),
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { explorationUnlocked: number };
|
|
expect(body.explorationUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not unlock exploration area when zone is not unlocked", async () => {
|
|
const state = makeState({
|
|
exploration: makeExploration([
|
|
{ id: "vm_e1", status: "locked" } as GameState["exploration"]["areas"][0],
|
|
]),
|
|
zones: [] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { explorationUnlocked: number };
|
|
expect(body.explorationUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock exploration area when it is already available", async () => {
|
|
const state = makeState({
|
|
exploration: makeExploration([
|
|
{ id: "verdant_meadow", status: "available" } as GameState["exploration"]["areas"][0],
|
|
]),
|
|
zones: [{ id: "verdant_vale", status: "unlocked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { explorationUnlocked: number };
|
|
expect(body.explorationUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks adventurer tier when its quest has been completed", async () => {
|
|
const state = makeState({
|
|
adventurers: [ { id: "scout", unlocked: false } ] as GameState["adventurers"],
|
|
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { adventurersUnlocked: number };
|
|
expect(body.adventurersUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not unlock adventurer tier when it is already unlocked", async () => {
|
|
const state = makeState({
|
|
adventurers: [ { id: "scout", unlocked: true } ] as GameState["adventurers"],
|
|
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { adventurersUnlocked: number };
|
|
expect(body.adventurersUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks upgrade when its boss has been defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
|
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { upgradesUnlocked: number };
|
|
expect(body.upgradesUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not unlock upgrade when boss is not defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
|
|
upgrades: [ { id: "click_2", unlocked: false } ] as GameState["upgrades"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { upgradesUnlocked: number };
|
|
expect(body.upgradesUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock upgrade when it is already unlocked", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
|
upgrades: [ { id: "click_2", unlocked: true } ] as GameState["upgrades"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { upgradesUnlocked: number };
|
|
expect(body.upgradesUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks upgrade granted as a quest reward", async () => {
|
|
const state = makeState({
|
|
quests: [ { id: "haunted_mine", status: "completed" } ] as GameState["quests"],
|
|
upgrades: [ { id: "global_1", unlocked: false } ] as GameState["upgrades"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { upgradesUnlocked: number };
|
|
expect(body.upgradesUnlocked).toBe(1);
|
|
});
|
|
|
|
it("marks equipment as owned when its boss has been defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
|
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { equipmentUnlocked: number };
|
|
expect(body.equipmentUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not mark equipment as owned when boss is not defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "troll_king", status: "available" } ] as GameState["bosses"],
|
|
equipment: [ { id: "iron_sword", owned: false } ] as GameState["equipment"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { equipmentUnlocked: number };
|
|
expect(body.equipmentUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not mark equipment as owned when it is already owned", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "troll_king", status: "defeated" } ] as GameState["bosses"],
|
|
equipment: [ { id: "iron_sword", owned: true } ] as GameState["equipment"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { equipmentUnlocked: number };
|
|
expect(body.equipmentUnlocked).toBe(0);
|
|
});
|
|
|
|
it("returns storyUnlocked=0 when story is undefined", async () => {
|
|
const state = makeState({
|
|
story: undefined as unknown as GameState["story"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { storyUnlocked: number };
|
|
expect(body.storyUnlocked).toBe(0);
|
|
});
|
|
|
|
it("unlocks story chapter when its boss has been defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
|
|
story: { completedChapters: [], unlockedChapterIds: [] },
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { storyUnlocked: number };
|
|
expect(body.storyUnlocked).toBe(1);
|
|
});
|
|
|
|
it("does not unlock story chapter when boss is not defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "forest_giant", status: "available" } ] as GameState["bosses"],
|
|
story: { completedChapters: [], unlockedChapterIds: [] },
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { storyUnlocked: number };
|
|
expect(body.storyUnlocked).toBe(0);
|
|
});
|
|
|
|
it("does not unlock story chapter when it is already unlocked", async () => {
|
|
const state = makeState({
|
|
bosses: [ { id: "forest_giant", status: "defeated" } ] as GameState["bosses"],
|
|
story: { completedChapters: [], unlockedChapterIds: [ "story_ch_01" ] },
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
const body = await res.json() as { storyUnlocked: number };
|
|
expect(body.storyUnlocked).toBe(0);
|
|
});
|
|
|
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
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("omits signature when ANTI_CHEAT_SECRET is not set", async () => {
|
|
delete process.env.ANTI_CHEAT_SECRET;
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await forceUnlocks();
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { signature: string | undefined };
|
|
expect(body.signature).toBeUndefined();
|
|
});
|
|
|
|
it("returns 500 when DB throws an Error", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await forceUnlocks();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when DB throws a non-Error value", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
|
const res = await forceUnlocks();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
const syncNewContent = () =>
|
|
app.fetch(new Request("http://localhost/debug/sync-new-content", { method: "POST" }));
|
|
|
|
describe("POST /sync-new-content", () => {
|
|
it("returns 404 when no game state found", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
const res = await syncNewContent();
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 200 with zero added counts when state already has all content", 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 { 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 () => {
|
|
const state = makeState({ adventurers: [], bosses: [], quests: [], upgrades: [], achievements: [], equipment: [], zones: [] });
|
|
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 { adventurersAdded: number; bossesAdded: number };
|
|
expect(body.adventurersAdded).toBeGreaterThan(0);
|
|
expect(body.bossesAdded).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("injects missing exploration areas when exploration has no areas", async () => {
|
|
const state = makeState({ exploration: makeExploration([]) });
|
|
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 { explorationAreasAdded: number };
|
|
expect(body.explorationAreasAdded).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("skips existing exploration areas when building the id set", async () => {
|
|
const state = makeState({ exploration: makeExploration([
|
|
{ id: "verdant_meadow", status: "available", completedOnce: false } as GameState["exploration"]["areas"][0],
|
|
]) });
|
|
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 { explorationAreasAdded: number };
|
|
// One area already existed so total injected is one less than full count
|
|
expect(body.explorationAreasAdded).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("returns explorationAreasAdded=0 when exploration state is undefined", async () => {
|
|
const state = makeState({ exploration: undefined as unknown as GameState["exploration"] });
|
|
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 { explorationAreasAdded: number };
|
|
expect(body.explorationAreasAdded).toBe(0);
|
|
});
|
|
|
|
it("patches quest rewards when saved quest has fewer rewards than default", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "first_steps", status: "available", rewards: [] }] as GameState["quests"],
|
|
});
|
|
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 { state: GameState };
|
|
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
|
expect(quest?.rewards.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("skips quest reward patching for quests not in defaults", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "nonexistent_quest", status: "available", rewards: [] }] as GameState["quests"],
|
|
});
|
|
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 { state: GameState };
|
|
const quest = body.state.quests.find((q) => q.id === "nonexistent_quest");
|
|
expect(quest?.rewards).toStrictEqual([]);
|
|
});
|
|
|
|
it("does not re-add rewards that are already present in the saved quest", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "adventurer", targetId: "militia" }] }] as GameState["quests"],
|
|
});
|
|
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 { state: GameState };
|
|
const quest = body.state.quests.find((q) => q.id === "first_steps");
|
|
// Reward already present so count stays the same
|
|
expect(quest?.rewards.filter((r) => r.targetId === "militia").length).toBe(1);
|
|
});
|
|
|
|
it("patches boss upgrade rewards when saved boss has fewer rewards than default", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "troll_king", status: "available", upgradeRewards: [] }] as GameState["bosses"],
|
|
});
|
|
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 { state: GameState };
|
|
const boss = body.state.bosses.find((b) => b.id === "troll_king");
|
|
expect(boss?.upgradeRewards.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("skips boss reward patching for bosses not in defaults", async () => {
|
|
const state = makeState({
|
|
bosses: [{ id: "nonexistent_boss", status: "available", upgradeRewards: [] }] as GameState["bosses"],
|
|
});
|
|
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 { state: GameState };
|
|
const boss = body.state.bosses.find((b) => b.id === "nonexistent_boss");
|
|
expect(boss?.upgradeRewards).toStrictEqual([]);
|
|
});
|
|
|
|
it("sorts multiple legacy items to the end when their ids are not in the defaults", async () => {
|
|
const state = makeState({
|
|
achievements: [
|
|
{ id: "legacy_achievement_a", status: "locked" },
|
|
{ id: "legacy_achievement_b", status: "locked" },
|
|
] as GameState["achievements"],
|
|
});
|
|
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);
|
|
});
|
|
|
|
it("uses amount field when building the reward key for quests with amount-based rewards", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "dragon_lair", status: "available", rewards: [{ type: "gold", amount: 500 }] }] as GameState["quests"],
|
|
});
|
|
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);
|
|
});
|
|
|
|
it("falls back to empty string when reward has neither targetId nor amount", async () => {
|
|
const state = makeState({
|
|
quests: [{ id: "first_steps", status: "available", rewards: [{ type: "unknown" }] }] as GameState["quests"],
|
|
});
|
|
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);
|
|
});
|
|
|
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
|
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 { 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 syncNewContent();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when DB throws a non-Error value", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw error");
|
|
const res = await syncNewContent();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("POST /hard-reset", () => {
|
|
it("returns 404 when no player found", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
|
const res = await hardReset();
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 200 with a fresh state on success", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await hardReset();
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as {
|
|
loginBonus: null;
|
|
loginStreak: number;
|
|
schemaOutdated: boolean;
|
|
};
|
|
expect(body.loginBonus).toBeNull();
|
|
expect(body.schemaOutdated).toBe(false);
|
|
expect(body.loginStreak).toBe(1);
|
|
});
|
|
|
|
it("computes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await hardReset();
|
|
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.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await hardReset();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when DB throws a non-Error value", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw error");
|
|
const res = await hardReset();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
});
|