generated from nhcarrigan/template
35e4d71d98
Fixes #39. Added bountyRunestonesClaimed?: boolean to the Boss type. The first-kill bounty runestones are now only awarded once across all prestige resets — the boss route checks the flag before awarding, sets it on first defeat, and buildPostPrestigeState carries the flag forward through fresh boss state on prestige. The boss panel badge no longer shows for bosses whose bounty has already been claimed.
330 lines
15 KiB
TypeScript
330 lines
15 KiB
TypeScript
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
|
|
/* eslint-disable max-lines -- Test suites naturally have many cases */
|
|
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Tests build minimal state objects */
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Hono } from "hono";
|
|
import type { GameState } from "@elysium/types";
|
|
|
|
vi.mock("../../src/db/client.js", () => ({
|
|
prisma: {
|
|
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
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";
|
|
|
|
const makeBoss = (overrides: Record<string, unknown> = {}) => ({
|
|
id: "test_boss",
|
|
zoneId: "test_zone",
|
|
status: "available",
|
|
prestigeRequirement: 0,
|
|
currentHp: 100,
|
|
maxHp: 100,
|
|
damagePerSecond: 1,
|
|
goldReward: 50,
|
|
essenceReward: 10,
|
|
crystalReward: 0,
|
|
upgradeRewards: [] as string[],
|
|
equipmentRewards: [] as string[],
|
|
...overrides,
|
|
});
|
|
|
|
const makeAdventurer = (overrides: Record<string, unknown> = {}) => ({
|
|
id: "test_adventurer",
|
|
count: 1,
|
|
combatPower: 10000, // Very high DPS to guarantee win
|
|
level: 10,
|
|
unlocked: true,
|
|
goldPerSecond: 1,
|
|
essencePerSecond: 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 },
|
|
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("boss route", () => {
|
|
let app: Hono;
|
|
let prisma: { gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } };
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const { bossRouter } = await import("../../src/routes/boss.js");
|
|
const { prisma: p } = await import("../../src/db/client.js");
|
|
prisma = p as typeof prisma;
|
|
app = new Hono();
|
|
app.route("/boss", bossRouter);
|
|
});
|
|
|
|
const challenge = (body: Record<string, unknown>) =>
|
|
app.fetch(new Request("http://localhost/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_boss" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 404 when boss is not in state", async () => {
|
|
const state = makeState({ bosses: [] });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("returns 400 when boss is already defeated", async () => {
|
|
const state = makeState({ bosses: [makeBoss({ status: "defeated" })] as GameState["bosses"] });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 403 when prestige requirement is not met", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ prestigeRequirement: 5 })] as GameState["bosses"],
|
|
prestige: { count: 0, runestones: 0, productionMultiplier: 1, purchasedUpgradeIds: [] },
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("returns 400 when party has no adventurers", async () => {
|
|
const state = makeState({ bosses: [makeBoss()] as GameState["bosses"], adventurers: [] });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns won=true when party defeats boss", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
|
adventurers: [makeAdventurer({ combatPower: 10000, count: 1, level: 10 })] as GameState["adventurers"],
|
|
zones: [{ id: "test_zone", status: "locked" }] as GameState["zones"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean; rewards: { gold: number } };
|
|
expect(body.won).toBe(true);
|
|
expect(body.rewards.gold).toBe(50);
|
|
});
|
|
|
|
it("returns won=false when party is defeated", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ currentHp: 1_000_000, maxHp: 1_000_000, damagePerSecond: 1_000_000 })] as GameState["bosses"],
|
|
// Include an adventurer with count=0 to cover the casualty-loop skip branch
|
|
adventurers: [
|
|
makeAdventurer({ combatPower: 1, count: 10, level: 1 }),
|
|
makeAdventurer({ id: "zero_count_adventurer", combatPower: 0, count: 0, level: 1 }),
|
|
] as GameState["adventurers"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean; casualties: Array<{ adventurerId: string }> };
|
|
expect(body.won).toBe(false);
|
|
expect(Array.isArray(body.casualties)).toBe(true);
|
|
});
|
|
|
|
it("skips zone unlock when zone is already unlocked and bossId matches", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
|
// Zone is already unlocked — the loop should skip it via the status==="unlocked" continue
|
|
zones: [{ id: "test_zone", status: "unlocked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
|
quests: [],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean };
|
|
expect(body.won).toBe(true);
|
|
});
|
|
|
|
it("skips zone unlock when quest condition is not satisfied", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
|
// Zone has unlockBossId matching but the required quest is not completed
|
|
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: "required_quest" }] as GameState["zones"],
|
|
quests: [{ id: "required_quest", status: "active" }] as GameState["quests"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean };
|
|
expect(body.won).toBe(true);
|
|
});
|
|
|
|
it("unlocks next zone boss when boss is defeated and zone condition is met", async () => {
|
|
const nextBoss = makeBoss({ id: "next_boss", status: "locked", prestigeRequirement: 0 });
|
|
const state = makeState({
|
|
bosses: [
|
|
makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 }),
|
|
nextBoss,
|
|
] as GameState["bosses"],
|
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
|
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: null }] as GameState["zones"],
|
|
quests: [],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean };
|
|
expect(body.won).toBe(true);
|
|
});
|
|
|
|
it("handles boss with upgrade and equipment rewards on win", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({
|
|
upgradeRewards: ["some_upgrade"],
|
|
equipmentRewards: ["some_equipment"],
|
|
})] as GameState["bosses"],
|
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
|
upgrades: [{ id: "some_upgrade", purchased: false, unlocked: false, target: "global", multiplier: 1 }] as GameState["upgrades"],
|
|
equipment: [{ id: "some_equipment", owned: false, equipped: false, type: "weapon", bonus: {} }] as GameState["equipment"],
|
|
zones: [],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean; rewards: { upgradeIds: string[]; equipmentIds: string[] } };
|
|
expect(body.won).toBe(true);
|
|
expect(body.rewards.upgradeIds).toContain("some_upgrade");
|
|
expect(body.rewards.equipmentIds).toContain("some_equipment");
|
|
});
|
|
|
|
it("updates daily challenge progress on boss defeat", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss()] as GameState["bosses"],
|
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
|
zones: [],
|
|
dailyChallenges: {
|
|
date: "2024-01-01",
|
|
challenges: [{ id: "boss_challenge", type: "bossesDefeated", target: 3, progress: 0, completed: false, crystalReward: 5 }],
|
|
} as GameState["dailyChallenges"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("applies adventurer-specific upgrade to party DPS", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
|
adventurers: [makeAdventurer({ id: "test_adventurer" })] as GameState["adventurers"],
|
|
upgrades: [{ id: "adv_upgrade", purchased: true, unlocked: true, target: "adventurer", adventurerId: "test_adventurer", multiplier: 2 }] as GameState["upgrades"],
|
|
zones: [],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_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 when global upgrade is purchased", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
|
adventurers: [makeAdventurer({ combatPower: 10000, count: 1 })] as GameState["adventurers"],
|
|
upgrades: [{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 }] as GameState["upgrades"],
|
|
zones: [],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean };
|
|
expect(body.won).toBe(true);
|
|
});
|
|
|
|
it("unlocks zone when boss defeated and quest condition is also satisfied", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({ currentHp: 100, maxHp: 100, damagePerSecond: 1 })] as GameState["bosses"],
|
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
|
zones: [{ id: "test_zone", status: "locked", unlockBossId: "test_boss", unlockQuestId: "test_quest" }] as GameState["zones"],
|
|
quests: [{ id: "test_quest", status: "completed" }] as GameState["quests"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean };
|
|
expect(body.won).toBe(true);
|
|
});
|
|
|
|
it("returns 500 when the database throws", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await challenge({ bossId: "test_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_boss" });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("does not re-award bounty runestones when bountyRunestonesClaimed is true", async () => {
|
|
const state = makeState({
|
|
bosses: [makeBoss({
|
|
bountyRunestonesClaimed: true,
|
|
currentHp: 100,
|
|
damagePerSecond: 1,
|
|
maxHp: 100,
|
|
})] as GameState["bosses"],
|
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
|
prestige: { count: 0, productionMultiplier: 1, purchasedUpgradeIds: [], runestones: 5 },
|
|
zones: [],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await challenge({ bossId: "test_boss" });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { won: boolean; rewards: { bountyRunestones: number } };
|
|
expect(body.won).toBe(true);
|
|
expect(body.rewards.bountyRunestones).toBe(0);
|
|
});
|
|
});
|