generated from nhcarrigan/template
feat: goddess API routes, services, and tests (chunk 4)
Add six new goddess-mode API routes (boss fight, consecration, enlightenment, upgrade purchase, crafting, exploration) alongside matching service modules and full test suites at 100% coverage.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
/* 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() },
|
||||
},
|
||||
}));
|
||||
|
||||
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),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
divinity: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
craftedRecipeIds: [] as string[],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a minimal GoddessState that has met the first consecration threshold (50 000 prayers).
|
||||
*/
|
||||
const makeGoddessStateEligible = (overrides: Record<string, unknown> = {}) => ({
|
||||
zones: [] as Array<{ id: string; status: string }>,
|
||||
bosses: [] as Array<{ id: string; status: string }>,
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
disciples: [] as Array<{ id: string; count: number }>,
|
||||
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number }>,
|
||||
achievements: [] as Array<{ id: string; completed: boolean }>,
|
||||
consecration: makeConsecration(),
|
||||
enlightenment: makeEnlightenment(),
|
||||
exploration: makeGoddessExploration(),
|
||||
totalPrayersEarned: 50_000, // Meets base threshold for count=0
|
||||
lifetimePrayersEarned: 50_000,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a minimal GoddessState that has NOT met the consecration threshold.
|
||||
*/
|
||||
const makeGoddessStateIneligible = (overrides: Record<string, unknown> = {}) => ({
|
||||
...makeGoddessStateEligible(),
|
||||
totalPrayersEarned: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
goddess: makeGoddessStateEligible() as GameState["goddess"],
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("consecration route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { consecrationRouter } = await import("../../src/routes/consecration.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/consecration", consecrationRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/consecration${path}`, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess realm is not unlocked", async () => {
|
||||
const state = makeState({ goddess: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Goddess realm not unlocked");
|
||||
});
|
||||
|
||||
it("returns 400 with threshold message when not eligible", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateIneligible() as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toMatch(/Not eligible for consecration/u);
|
||||
expect(body.error).toMatch(/50,000/u);
|
||||
});
|
||||
|
||||
it("returns 200 with divinityEarned and newConsecrationCount on success", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { divinityEarned: number; newConsecrationCount: number };
|
||||
expect(body.newConsecrationCount).toBe(1);
|
||||
expect(body.divinityEarned).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("applies the threshold multiplier when checking eligibility", async () => {
|
||||
// threshold multiplier of 2 means we need 100 000 prayers for count=0 but only have 50 000
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateIneligible({
|
||||
totalPrayersEarned: 50_000,
|
||||
enlightenment: makeEnlightenment({ stardustConsecrationThresholdMultiplier: 2 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
// threshold should be 100 000 with multiplier 2
|
||||
expect(body.error).toMatch(/100,000/u);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("upgradeId is required");
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown upgrade", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade_id" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Unknown consecration upgrade");
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess realm is not unlocked", async () => {
|
||||
const state = makeState({ goddess: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Goddess realm not unlocked");
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateEligible({
|
||||
consecration: makeConsecration({
|
||||
divinity: 100,
|
||||
purchasedUpgradeIds: [ "divine_prayers_1" ],
|
||||
}),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Upgrade already purchased");
|
||||
});
|
||||
|
||||
it("returns 400 when not enough divinity", async () => {
|
||||
// divine_prayers_1 costs 5 divinity — give the player 0
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateEligible({
|
||||
consecration: makeConsecration({ divinity: 0 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Not enough divinity");
|
||||
});
|
||||
|
||||
it("returns 200 with updated multipliers on successful purchase", async () => {
|
||||
// divine_prayers_1 costs 5 divinity and is in the "prayers" category
|
||||
const state = makeState({
|
||||
goddess: makeGoddessStateEligible({
|
||||
consecration: makeConsecration({ divinity: 100 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
divinityRemaining: number;
|
||||
purchasedUpgradeIds: string[];
|
||||
divinityPrayersMultiplier: number;
|
||||
divinityDisciplesMultiplier: number;
|
||||
divinityCombatMultiplier: number;
|
||||
};
|
||||
expect(body.divinityRemaining).toBe(95); // 100 - 5
|
||||
expect(body.purchasedUpgradeIds).toContain("divine_prayers_1");
|
||||
expect(body.divinityPrayersMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws an Error during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when database throws a non-Error value during buy-upgrade", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("/buy-upgrade", { upgradeId: "divine_prayers_1" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/* 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() },
|
||||
},
|
||||
}));
|
||||
|
||||
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();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// stardust_prayers_1 costs 2 stardust
|
||||
const TEST_UPGRADE_ID = "stardust_prayers_1";
|
||||
|
||||
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [{ id: "goddess_celestial_garden", name: "Celestial Garden", description: "", emoji: "🌸", status: "unlocked", unlockBossId: null, unlockQuestId: null }],
|
||||
bosses: [
|
||||
{
|
||||
id: "divine_heart_sovereign",
|
||||
name: "Divine Heart Sovereign",
|
||||
description: "",
|
||||
status: "defeated",
|
||||
maxHp: 1000,
|
||||
currentHp: 0,
|
||||
damagePerSecond: 10,
|
||||
prayersReward: 100,
|
||||
divinityReward: 1,
|
||||
stardustReward: 1,
|
||||
upgradeRewards: [],
|
||||
equipmentRewards: [],
|
||||
consecrationRequirement: 0,
|
||||
zoneId: "goddess_celestial_garden",
|
||||
bountyDivinity: 5,
|
||||
},
|
||||
],
|
||||
quests: [],
|
||||
disciples: [],
|
||||
equipment: [],
|
||||
upgrades: [],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 10,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 10,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("enlightenment route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { enlightenmentRouter } = await import("../../src/routes/enlightenment.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/enlightenment", enlightenmentRouter);
|
||||
});
|
||||
|
||||
const post = (path: string, body?: Record<string, unknown>) =>
|
||||
app.fetch(new Request(`http://localhost/enlightenment${path}`, {
|
||||
method: "POST",
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}));
|
||||
|
||||
describe("POST /", () => {
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not eligible (final boss not defeated)", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
// Override final boss to available (not defeated)
|
||||
goddess.bosses[0]!.status = "available";
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with stardustEarned and newEnlightenmentCount on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.consecration.count = 4; // sqrt(4)*1 = 2 stardust
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { stardustEarned: number; newEnlightenmentCount: number };
|
||||
expect(body.newEnlightenmentCount).toBe(1);
|
||||
expect(body.stardustEarned).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /buy-upgrade", () => {
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post("/buy-upgrade", {});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown upgrade", async () => {
|
||||
const res = await post("/buy-upgrade", { upgradeId: "nonexistent_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.enlightenment.purchasedUpgradeIds = [TEST_UPGRADE_ID];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough stardust", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.enlightenment.stardust = 0; // stardust_prayers_1 costs 2
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with updated multipliers on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.enlightenment.stardust = 10;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { stardustRemaining: number; purchasedUpgradeIds: string[] };
|
||||
expect(body.stardustRemaining).toBe(8); // 10 - 2
|
||||
expect(body.purchasedUpgradeIds).toContain(TEST_UPGRADE_ID);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post("/buy-upgrade", { upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
/* 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() },
|
||||
},
|
||||
}));
|
||||
|
||||
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),
|
||||
metric: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
|
||||
const makeConsecration = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
divinity: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeEnlightenment = (overrides: Record<string, unknown> = {}) => ({
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [] as string[],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessExploration = (overrides: Record<string, unknown> = {}) => ({
|
||||
areas: [] as Array<{ id: string; status: string }>,
|
||||
materials: [] as Array<{ materialId: string; quantity: number }>,
|
||||
craftedRecipeIds: [] as string[],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessBoss = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_goddess_boss",
|
||||
name: "Test Goddess Boss",
|
||||
description: "A test boss",
|
||||
status: "available",
|
||||
maxHp: 100,
|
||||
currentHp: 100,
|
||||
damagePerSecond: 1,
|
||||
prayersReward: 50,
|
||||
divinityReward: 2,
|
||||
stardustReward: 0,
|
||||
upgradeRewards: [] as string[],
|
||||
equipmentRewards: [] as string[],
|
||||
consecrationRequirement: 0,
|
||||
zoneId: "test_goddess_zone",
|
||||
bountyDivinity: 5,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDisciple = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "test_disciple",
|
||||
name: "Test Disciple",
|
||||
class: "oracle" as const,
|
||||
level: 10,
|
||||
baseCost: 100,
|
||||
prayersPerSecond: 1,
|
||||
divinityPerSecond: 0,
|
||||
combatPower: 10_000,
|
||||
count: 1,
|
||||
unlocked: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGoddessState = (overrides: Record<string, unknown> = {}) => ({
|
||||
zones: [] as Array<{ id: string; status: string; unlockBossId: string | null; unlockQuestId: string | null }>,
|
||||
bosses: [ makeGoddessBoss() ],
|
||||
quests: [] as Array<{ id: string; status: string }>,
|
||||
disciples: [ makeDisciple() ],
|
||||
equipment: [] as Array<{ id: string; type: string; owned: boolean; equipped: boolean; bonus: Record<string, unknown> }>,
|
||||
upgrades: [] as Array<{ id: string; purchased: boolean; target: string; multiplier: number; discipleId?: string }>,
|
||||
achievements: [] as Array<{ id: string; completed: boolean }>,
|
||||
consecration: makeConsecration(),
|
||||
enlightenment: makeEnlightenment(),
|
||||
exploration: makeGoddessExploration(),
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
goddess: makeGoddessState() as GameState["goddess"],
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("goddessBoss route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessBossRouter } = await import("../../src/routes/goddessBoss.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-boss", goddessBossRouter);
|
||||
});
|
||||
|
||||
const challenge = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-boss/challenge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when bossId is missing", async () => {
|
||||
const res = await challenge({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess realm is not unlocked", async () => {
|
||||
const state = makeState({ goddess: undefined });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Goddess realm not unlocked");
|
||||
});
|
||||
|
||||
it("returns 404 when boss is not found in goddess state", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({ bosses: [] }) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Boss not found");
|
||||
});
|
||||
|
||||
it("returns 400 when boss status is defeated", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ status: "defeated" }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Boss is not currently available");
|
||||
});
|
||||
|
||||
it("returns 400 when boss status is locked", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ status: "locked" }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts in_progress boss status", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ status: "in_progress" }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 when consecration requirement is not met", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ consecrationRequirement: 3 }) ],
|
||||
consecration: makeConsecration({ count: 0 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Consecration requirement not met");
|
||||
});
|
||||
|
||||
it("returns 400 when party has no combat power (all disciples have count 0)", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
disciples: [ makeDisciple({ count: 0 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error: string };
|
||||
expect(body.error).toBe("Your disciples have no combat power");
|
||||
});
|
||||
|
||||
it("returns 400 when party has no combat power (disciples array is empty)", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
disciples: [],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with rewards when party wins", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1, level: 10 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { prayers: number; divinity: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.prayers).toBe(50);
|
||||
expect(body.rewards.divinity).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 200 with bountyDivinity when first kill", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 50, maxHp: 50, damagePerSecond: 1, id: "celestial_sprite" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "celestial_sprite" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.bountyDivinity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns 0 bountyDivinity when already claimed", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "celestial_sprite", bountyDivinityClaimed: true }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "celestial_sprite" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { bountyDivinity: number } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.bountyDivinity).toBe(0);
|
||||
});
|
||||
|
||||
it("unlocks upgrade rewards on win", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ upgradeRewards: [ "test_upgrade" ] }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
upgrades: [ { id: "test_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; rewards: { upgradeIds: string[] } };
|
||||
expect(body.won).toBe(true);
|
||||
expect(body.rewards.upgradeIds).toContain("test_upgrade");
|
||||
});
|
||||
|
||||
it("unlocks next zone boss when boss is defeated", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [
|
||||
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
|
||||
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 0 }),
|
||||
],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("does not unlock next boss if consecration requirement not met", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [
|
||||
makeGoddessBoss({ id: "test_goddess_boss", zoneId: "test_goddess_zone", status: "available" }),
|
||||
makeGoddessBoss({ id: "next_goddess_boss", zoneId: "test_goddess_zone", status: "locked", consecrationRequirement: 5 }),
|
||||
],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
consecration: makeConsecration({ count: 0 }),
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks goddess zone when boss and quest conditions are both met", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
|
||||
],
|
||||
quests: [ { id: "test_quest", status: "completed" } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("does not unlock zone when quest condition is not satisfied", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: "test_quest" },
|
||||
],
|
||||
quests: [ { id: "test_quest", status: "active" } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks zone when unlockQuestId is null", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "locked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("skips zone that is already unlocked", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "test_goddess_zone", status: "unlocked", unlockBossId: "test_goddess_boss", unlockQuestId: null },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("skips zone whose unlockBossId does not match the defeated boss", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ id: "test_goddess_boss" }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 100_000, count: 1 }) ],
|
||||
zones: [
|
||||
{ id: "other_zone", status: "locked", unlockBossId: "different_boss", unlockQuestId: null },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("applies global upgrade multiplier to party DPS", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
|
||||
upgrades: [ { id: "global_u", purchased: true, target: "global", multiplier: 100_000 } ],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("applies disciple-specific upgrade multiplier", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ id: "test_disciple", combatPower: 1, count: 1, level: 10 }) ],
|
||||
upgrades: [
|
||||
{ id: "disciple_u", purchased: true, target: "disciple", multiplier: 100_000, discipleId: "test_disciple" },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(true);
|
||||
});
|
||||
|
||||
it("skips unpurchased upgrades", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [ makeGoddessBoss({ currentHp: 100_000, damagePerSecond: 1 }) ],
|
||||
disciples: [ makeDisciple({ combatPower: 1, count: 1, level: 10 }) ],
|
||||
upgrades: [
|
||||
{ id: "not_bought", purchased: false, target: "global", multiplier: 100_000 },
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean };
|
||||
expect(body.won).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 with casualties when party loses", async () => {
|
||||
const state = makeState({
|
||||
goddess: makeGoddessState({
|
||||
bosses: [
|
||||
makeGoddessBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 }),
|
||||
],
|
||||
disciples: [
|
||||
makeDisciple({ combatPower: 1, count: 10, level: 1 }),
|
||||
makeDisciple({ id: "zero_disciple", combatPower: 0, count: 0, level: 1 }),
|
||||
],
|
||||
}) as GameState["goddess"],
|
||||
});
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { won: boolean; casualties: Array<{ discipleId: string }> };
|
||||
expect(body.won).toBe(false);
|
||||
expect(Array.isArray(body.casualties)).toBe(true);
|
||||
});
|
||||
|
||||
it("includes HMAC signature in response 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 challenge({ bossId: "test_goddess_boss" });
|
||||
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 challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { signature: string | undefined };
|
||||
expect(body.signature).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await challenge({ bossId: "test_goddess_boss" });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/* 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() },
|
||||
},
|
||||
}));
|
||||
|
||||
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();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// prayer_amplifier requires: divine_petal×3, prayer_crystal×2; bonus: gold_income 1.1
|
||||
const TEST_RECIPE_ID = "prayer_amplifier";
|
||||
|
||||
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [],
|
||||
bosses: [],
|
||||
quests: [],
|
||||
disciples: [],
|
||||
equipment: [],
|
||||
upgrades: [],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 0,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [
|
||||
{ materialId: "divine_petal", quantity: 5 },
|
||||
{ materialId: "prayer_crystal", quantity: 5 },
|
||||
],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("goddessCraft route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessCraftRouter } = await import("../../src/routes/goddessCraft.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-craft", goddessCraftRouter);
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-craft", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when recipeId is missing", async () => {
|
||||
const res = await post({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown recipe", async () => {
|
||||
const res = await post({ recipeId: "nonexistent_recipe" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when recipe is already crafted", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.exploration.craftedRecipeIds = [TEST_RECIPE_ID];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough material (first requirement)", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.exploration.materials = [
|
||||
{ materialId: "divine_petal", quantity: 1 }, // needs 3
|
||||
{ materialId: "prayer_crystal", quantity: 5 },
|
||||
];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when material is completely absent", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.exploration.materials = []; // neither material present
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with updated multipliers and materials on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as {
|
||||
recipeId: string;
|
||||
bonusType: string;
|
||||
bonusValue: number;
|
||||
craftedPrayersMultiplier: number;
|
||||
craftedDivinityMultiplier: number;
|
||||
craftedCombatMultiplier: number;
|
||||
materials: Array<{ materialId: string; quantity: number }>;
|
||||
};
|
||||
expect(body.recipeId).toBe(TEST_RECIPE_ID);
|
||||
expect(body.bonusType).toBe("gold_income");
|
||||
expect(body.bonusValue).toBe(1.1);
|
||||
expect(body.craftedPrayersMultiplier).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,619 @@
|
||||
/* 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, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { GameState, GoddessExplorationArea } from "@elysium/types";
|
||||
|
||||
vi.mock("../../src/db/client.js", () => ({
|
||||
prisma: {
|
||||
gameState: { findUnique: vi.fn(), update: 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();
|
||||
}),
|
||||
}));
|
||||
|
||||
// Custom test areas exercising event types not present in the real data
|
||||
const PRAYERS_LOSS_AREA: GoddessExplorationArea = {
|
||||
description: "Test area for prayers_loss events",
|
||||
durationSeconds: 1,
|
||||
events: [
|
||||
{ effect: { amount: 100, type: "prayers_loss" }, id: "test_prayers_loss", text: "You lost some prayers." },
|
||||
],
|
||||
id: "test_prayers_loss_area",
|
||||
name: "Test Prayers Loss Area",
|
||||
possibleMaterials: [],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
};
|
||||
|
||||
const DIVINITY_GAIN_AREA: GoddessExplorationArea = {
|
||||
description: "Test area for divinity_gain events",
|
||||
durationSeconds: 1,
|
||||
events: [
|
||||
{ effect: { amount: 10, type: "divinity_gain" }, id: "test_divinity_gain", text: "You gained divinity." },
|
||||
],
|
||||
id: "test_divinity_gain_area",
|
||||
name: "Test Divinity Gain Area",
|
||||
possibleMaterials: [],
|
||||
zoneId: "goddess_celestial_garden",
|
||||
};
|
||||
|
||||
vi.mock("../../src/data/goddessExplorations.js", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../../src/data/goddessExplorations.js")>();
|
||||
return {
|
||||
defaultGoddessExplorationAreas: [
|
||||
...original.defaultGoddessExplorationAreas,
|
||||
PRAYERS_LOSS_AREA,
|
||||
DIVINITY_GAIN_AREA,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// garden_glade: zoneId=goddess_celestial_garden, durationSeconds=30
|
||||
// events[0]: prayers_gain 50; events[1]: disciple_loss 0.05
|
||||
// possibleMaterials: divine_petal(weight 5), prayer_crystal(weight 3) — total 8
|
||||
const TEST_AREA_ID = "garden_glade";
|
||||
const TEST_ZONE_ID = "goddess_celestial_garden";
|
||||
|
||||
// celestial_meadow: durationSeconds=60
|
||||
// events[0]: prayers_gain 200; events[1]: sacred_material_gain celestial_dust qty 2
|
||||
const MATERIAL_AREA_ID = "celestial_meadow";
|
||||
|
||||
const makeGoddessState = (areaId: string, zoneStatus: "unlocked" | "locked" = "unlocked"): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [
|
||||
{
|
||||
id: TEST_ZONE_ID,
|
||||
name: "Celestial Garden",
|
||||
description: "",
|
||||
emoji: "🌸",
|
||||
status: zoneStatus,
|
||||
unlockBossId: null,
|
||||
unlockQuestId: null,
|
||||
},
|
||||
],
|
||||
bosses: [],
|
||||
quests: [],
|
||||
disciples: [
|
||||
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
|
||||
],
|
||||
equipment: [],
|
||||
upgrades: [],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 50,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 0,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [
|
||||
{ id: areaId, status: "available" },
|
||||
],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
/** Builds a state with the area in_progress and startedAt in the past so it's complete. */
|
||||
const makeCompletedAreaState = (
|
||||
areaId: string,
|
||||
extraMaterials: Array<{ materialId: string; quantity: number }> = [],
|
||||
extraPrayers = 0,
|
||||
): GameState => {
|
||||
const goddess = makeGoddessState(areaId);
|
||||
goddess.exploration.areas = [{ id: areaId, status: "in_progress", startedAt: 0 }];
|
||||
goddess.exploration.materials = extraMaterials;
|
||||
return makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: extraPrayers } });
|
||||
};
|
||||
|
||||
describe("goddessExplore route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessExploreRouter } = await import("../../src/routes/goddessExplore.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-explore", goddessExploreRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const getClaimable = (areaId?: string) => {
|
||||
const url = areaId === undefined
|
||||
? "http://localhost/goddess-explore/claimable"
|
||||
: `http://localhost/goddess-explore/claimable?areaId=${areaId}`;
|
||||
return app.fetch(new Request(url));
|
||||
};
|
||||
|
||||
const postStart = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-explore/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
const postCollect = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-explore/collect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
describe("GET /claimable", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await getClaimable();
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await getClaimable("nonexistent_area");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns claimable=false when goddess is undefined", async () => {
|
||||
const state = makeState(); // no goddess
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when area is not in_progress", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// area status is "available" (not in_progress)
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when area not found in state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = []; // area missing entirely
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=false when exploration is not yet complete", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// startedAt = now → not complete yet (duration is 30s)
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(false);
|
||||
});
|
||||
|
||||
it("returns claimable=true when exploration is complete", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// startedAt = 0 → expired long ago
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { claimable: boolean };
|
||||
expect(body.claimable).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await getClaimable(TEST_AREA_ID);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /start", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await postStart({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await postStart({ areaId: "nonexistent_area" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when zone is not unlocked", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID, "locked");
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when zone is not found in goddess state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.zones = []; // zone missing
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when area is not found in state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = []; // area missing
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when another exploration is already in progress", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [
|
||||
{ id: TEST_AREA_ID, status: "available" },
|
||||
{ id: "other_area", status: "in_progress" },
|
||||
];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when area is locked", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "locked" }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with areaId and endsAt on success", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { areaId: string; endsAt: number };
|
||||
expect(body.areaId).toBe(TEST_AREA_ID);
|
||||
expect(body.endsAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await postStart({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /collect", () => {
|
||||
it("returns 400 when areaId is missing", async () => {
|
||||
const res = await postCollect({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown area", async () => {
|
||||
const res = await postCollect({ areaId: "nonexistent_area" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when area is not found in state", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when area is not in_progress", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// area is "available", not "in_progress"
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when exploration is not yet complete", async () => {
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
// startedAt = now → still in progress for 30s
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: Date.now() }];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns foundNothing=true when Math.random is below 0.2 (nothing path)", async () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.1); // < 0.2 → nothing
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; nothingMessage: string; materialsFound: unknown[] };
|
||||
expect(body.foundNothing).toBe(true);
|
||||
expect(typeof body.nothingMessage).toBe("string");
|
||||
});
|
||||
|
||||
it("applies prayers_gain event and returns prayersChange > 0", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// garden_glade has 2 events: [prayers_gain(0), disciple_loss(1)]
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.1 * 2) = 0 → prayers_gain
|
||||
// Call 3: possibleMaterials roll (total weight 8): 0 * 8 = 0, 0 - 5 = -5 ≤ 0 → divine_petal
|
||||
// Call 4: quantity Math.floor(0 * (3-1+1)) + 1 = 0 + 1 = 1
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5)
|
||||
.mockReturnValueOnce(0.1)
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(0);
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { foundNothing: boolean; event: { prayersChange: number }; materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.foundNothing).toBe(false);
|
||||
expect(body.event.prayersChange).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("applies sacred_material_gain event and pushes new material", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// celestial_meadow: events[1] = sacred_material_gain celestial_dust qty 2
|
||||
// index 1: Math.floor(0.6 * 2) = 1
|
||||
// possibleMaterials: [divine_petal(4), celestial_dust(3)] total 7
|
||||
// Call 3: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal (new material)
|
||||
// Call 4: Math.floor(0 * (4-2+1)) + 2 = 0 + 2 = 2
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.6) // event index: Math.floor(0.6 * 2) = 1 → sacred_material_gain
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll: 0 * 7 = 0, 0 - 4 = -4 ≤ 0 → divine_petal
|
||||
.mockReturnValueOnce(0); // quantity: Math.floor(0 * 3) + 2 = 2
|
||||
const state = makeCompletedAreaState(MATERIAL_AREA_ID); // no materials in state
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } }; materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
|
||||
});
|
||||
|
||||
it("increments existing material quantity on sacred_material_gain event", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// Same as above — celestial_meadow events[1] = sacred_material_gain celestial_dust
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.6) // event: sacred_material_gain
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
|
||||
.mockReturnValueOnce(0); // quantity → 2
|
||||
const state = makeCompletedAreaState(MATERIAL_AREA_ID, [
|
||||
{ materialId: "celestial_dust", quantity: 5 }, // pre-existing
|
||||
]);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: MATERIAL_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { materialGained: { materialId: string; quantity: number } } };
|
||||
expect(body.event.materialGained?.materialId).toBe("celestial_dust");
|
||||
expect(body.event.materialGained?.quantity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns materialsFound with new material from possibleMaterials when none pre-existing", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// prayers_gain event, then roll for divine_petal (new)
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal (first, weight 5)
|
||||
.mockReturnValueOnce(0); // quantity → min (1)
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID); // no materials
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { materialsFound: Array<{ materialId: string; quantity: number }> };
|
||||
expect(body.materialsFound.length).toBeGreaterThan(0);
|
||||
expect(body.materialsFound[0]?.materialId).toBe("divine_petal");
|
||||
});
|
||||
|
||||
it("increments existing possibleMaterial quantity when material is already in state", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.1) // event: prayers_gain (index 0)
|
||||
.mockReturnValueOnce(0) // possibleMaterials roll → divine_petal
|
||||
.mockReturnValueOnce(0); // quantity → 1
|
||||
const state = makeCompletedAreaState(TEST_AREA_ID, [
|
||||
{ materialId: "divine_petal", quantity: 10 }, // pre-existing
|
||||
]);
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { materialsFound: Array<{ materialId: string }> };
|
||||
expect(body.materialsFound.some((m) => {
|
||||
return m.materialId === "divine_petal";
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("applies disciple_loss event and reduces disciple counts", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// garden_glade events[1] = disciple_loss fraction 0.05
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.6 * 2) = 1 → disciple_loss
|
||||
// possibleMaterials: total weight 8; call 3: 0.9 * 8 = 7.2; 7.2 - 5 = 2.2 > 0; 2.2 - 3 = -0.8 ≤ 0 → prayer_crystal
|
||||
// Call 4: quantity for prayer_crystal: Math.floor(0 * (2-1+1)) + 1 = 1
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5) // nothing check: proceed
|
||||
.mockReturnValueOnce(0.6) // event: Math.floor(0.6 * 2) = 1 → disciple_loss
|
||||
.mockReturnValueOnce(0.9) // possibleMaterials roll → prayer_crystal
|
||||
.mockReturnValueOnce(0); // quantity → 1
|
||||
const goddess = makeGoddessState(TEST_AREA_ID);
|
||||
goddess.exploration.areas = [{ id: TEST_AREA_ID, status: "in_progress", startedAt: 0 }];
|
||||
// Need disciples with non-zero count so lost > 0 triggers
|
||||
goddess.disciples = [
|
||||
{ id: "novice", name: "Novice", count: 100, costPrayers: 10, prayersPerSecond: 1, description: "", zoneId: TEST_ZONE_ID },
|
||||
];
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("applies prayers_loss event and returns negative prayersChange", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// test_prayers_loss_area has 1 event: prayers_loss 100
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.1 * 1) = 0 → prayers_loss
|
||||
// No possibleMaterials, so no further calls needed
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5)
|
||||
.mockReturnValueOnce(0.1);
|
||||
const goddess = makeGoddessState(PRAYERS_LOSS_AREA.id);
|
||||
goddess.exploration.areas = [{ id: PRAYERS_LOSS_AREA.id, status: "in_progress", startedAt: 0 }];
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: PRAYERS_LOSS_AREA.id });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { event: { prayersChange: number }; foundNothing: boolean };
|
||||
expect(body.foundNothing).toBe(false);
|
||||
expect(body.event.prayersChange).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("applies divinity_gain event and increases divinity", async () => {
|
||||
const mockRandom = vi.spyOn(Math, "random");
|
||||
// test_divinity_gain_area has 1 event: divinity_gain 10
|
||||
// Call 1: nothing check 0.5 ≥ 0.2 → proceed
|
||||
// Call 2: event index Math.floor(0.1 * 1) = 0 → divinity_gain
|
||||
// No possibleMaterials
|
||||
mockRandom
|
||||
.mockReturnValueOnce(0.5)
|
||||
.mockReturnValueOnce(0.1);
|
||||
const goddess = makeGoddessState(DIVINITY_GAIN_AREA.id);
|
||||
goddess.exploration.areas = [{ id: DIVINITY_GAIN_AREA.id, status: "in_progress", startedAt: 0 }];
|
||||
const initialDivinity = goddess.consecration.divinity;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await postCollect({ areaId: DIVINITY_GAIN_AREA.id });
|
||||
expect(res.status).toBe(200);
|
||||
// Verify state was saved with updated divinity
|
||||
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
|
||||
data: { state: { goddess: { consecration: { divinity: number } } } };
|
||||
};
|
||||
expect(updateArg.data.state.goddess.consecration.divinity).toBe(initialDivinity + 10);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await postCollect({ areaId: TEST_AREA_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/* 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() },
|
||||
},
|
||||
}));
|
||||
|
||||
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();
|
||||
}),
|
||||
}));
|
||||
|
||||
const DISCORD_ID = "test_discord_id";
|
||||
// prayer_offering_1 costs 50 prayers, 0 divinity, 0 stardust; unlocked: true
|
||||
const TEST_UPGRADE_ID = "prayer_offering_1";
|
||||
|
||||
const makeGoddessState = (): NonNullable<GameState["goddess"]> => ({
|
||||
zones: [],
|
||||
bosses: [],
|
||||
quests: [],
|
||||
disciples: [],
|
||||
equipment: [],
|
||||
upgrades: [
|
||||
{
|
||||
id: TEST_UPGRADE_ID,
|
||||
name: "Morning Offering I",
|
||||
description: "",
|
||||
target: "prayers",
|
||||
multiplier: 1.25,
|
||||
costPrayers: 50,
|
||||
costDivinity: 0,
|
||||
costStardust: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
},
|
||||
],
|
||||
achievements: [],
|
||||
consecration: {
|
||||
count: 0,
|
||||
divinity: 100,
|
||||
productionMultiplier: 1,
|
||||
purchasedUpgradeIds: [],
|
||||
},
|
||||
enlightenment: {
|
||||
count: 0,
|
||||
stardust: 100,
|
||||
purchasedUpgradeIds: [],
|
||||
stardustPrayersMultiplier: 1,
|
||||
stardustCombatMultiplier: 1,
|
||||
stardustConsecrationThresholdMultiplier: 1,
|
||||
stardustConsecrationDivinityMultiplier: 1,
|
||||
stardustMetaMultiplier: 1,
|
||||
},
|
||||
exploration: {
|
||||
areas: [],
|
||||
materials: [],
|
||||
craftedRecipeIds: [],
|
||||
craftedPrayersMultiplier: 1,
|
||||
craftedDivinityMultiplier: 1,
|
||||
craftedCombatMultiplier: 1,
|
||||
},
|
||||
totalPrayersEarned: 0,
|
||||
lifetimePrayersEarned: 0,
|
||||
lifetimeBossesDefeated: 0,
|
||||
lifetimeQuestsCompleted: 0,
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
});
|
||||
|
||||
const makeState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
player: { discordId: DISCORD_ID, username: "u", discriminator: "0", avatar: null, totalGoldEarned: 0, totalClicks: 0, characterName: "T" },
|
||||
resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 },
|
||||
adventurers: [],
|
||||
upgrades: [],
|
||||
quests: [],
|
||||
bosses: [],
|
||||
equipment: [],
|
||||
achievements: [],
|
||||
zones: [],
|
||||
exploration: { areas: [], materials: [], craftedRecipeIds: [], craftedGoldMultiplier: 1, craftedEssenceMultiplier: 1, craftedClickMultiplier: 1, craftedCombatMultiplier: 1 },
|
||||
companions: { unlockedCompanionIds: [], activeCompanionId: null },
|
||||
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
||||
baseClickPower: 1,
|
||||
lastTickAt: 0,
|
||||
schemaVersion: 1,
|
||||
...overrides,
|
||||
} as GameState);
|
||||
|
||||
describe("goddessUpgrade route", () => {
|
||||
let app: Hono;
|
||||
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { goddessUpgradeRouter } = await import("../../src/routes/goddessUpgrade.js");
|
||||
const { prisma: p } = await import("../../src/db/client.js");
|
||||
prisma = p as typeof prisma;
|
||||
app = new Hono();
|
||||
app.route("/goddess-upgrade", goddessUpgradeRouter);
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
app.fetch(new Request("http://localhost/goddess-upgrade/buy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
|
||||
it("returns 400 when upgradeId is missing", async () => {
|
||||
const res = await post({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown upgrade", async () => {
|
||||
const res = await post({ upgradeId: "nonexistent_upgrade" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when no save is found", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when goddess is undefined", async () => {
|
||||
const state = makeState();
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when upgrade is not found in goddess state", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades = []; // no upgrades in state
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is not yet unlocked", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades[0]!.unlocked = false;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when upgrade is already purchased", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades[0]!.purchased = true;
|
||||
const state = makeState({ goddess });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when prayers is undefined (treats as 0)", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
// Omitting prayers entirely exercises the `?? 0` fallback on line 75
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough prayers", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 10 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough divinity", async () => {
|
||||
// prayer_offering_3 costs 1 divinity
|
||||
const upgradeId = "prayer_offering_3";
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades.push({
|
||||
id: upgradeId,
|
||||
name: "Morning Offering III",
|
||||
description: "",
|
||||
target: "prayers",
|
||||
multiplier: 2,
|
||||
costPrayers: 1000,
|
||||
costDivinity: 1,
|
||||
costStardust: 0,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
});
|
||||
goddess.consecration.divinity = 0; // need 1 but have 0
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 5000 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when not enough stardust", async () => {
|
||||
// divine_spark_2 is in defaultGoddessUpgrades with costStardust: 1, costDivinity: 100, costPrayers: 500_000
|
||||
const upgradeId = "divine_spark_2";
|
||||
const goddess = makeGoddessState();
|
||||
goddess.upgrades.push({
|
||||
id: upgradeId,
|
||||
name: "Divine Spark II",
|
||||
description: "",
|
||||
target: "prayers",
|
||||
multiplier: 25,
|
||||
costPrayers: 500_000,
|
||||
costDivinity: 100,
|
||||
costStardust: 1,
|
||||
purchased: false,
|
||||
unlocked: true,
|
||||
});
|
||||
goddess.consecration.divinity = 100; // enough divinity
|
||||
goddess.enlightenment.stardust = 0; // NOT enough stardust (need 1)
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 500_000 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
const res = await post({ upgradeId });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with remaining resources on success", async () => {
|
||||
const goddess = makeGoddessState();
|
||||
const state = makeState({ goddess, resources: { gold: 0, essence: 0, crystals: 0, runestones: 0, prayers: 200 } });
|
||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { prayersRemaining: number; divinityRemaining: number; stardustRemaining: number };
|
||||
expect(body.prayersRemaining).toBe(150); // 200 - 50
|
||||
expect(body.divinityRemaining).toBe(100); // no divinity cost
|
||||
expect(body.stardustRemaining).toBe(100); // no stardust cost
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws an Error", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when the database throws a non-Error value", async () => {
|
||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
||||
const res = await post({ upgradeId: TEST_UPGRADE_ID });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user