generated from nhcarrigan/template
830a9d2a56
Adds fetchDiscordUserById (bot token) to the Discord service and calls it in parallel with the DB queries on game load. When the returned hash differs from the stored value the Player record is updated and the hash is immediately synced into the returned game state, so the resource bar always shows the player's current Discord avatar. Also adds onError fallback: if the avatar URL is stale before the next load, the resource bar component now derives the URL fresh from state on every render rather than caching it.
579 lines
31 KiB
TypeScript
579 lines
31 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Hono } from "hono";
|
|
import type { GameState } from "@elysium/types";
|
|
|
|
vi.mock("../../src/db/client.js", () => ({
|
|
prisma: {
|
|
player: { findUnique: vi.fn(), update: vi.fn() },
|
|
gameState: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), upsert: 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/discord.js", () => ({
|
|
fetchDiscordUserById: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
const DISCORD_ID = "test_discord_id";
|
|
const CURRENT_SCHEMA_VERSION = 1;
|
|
|
|
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: Date.now() - 60_000, // 60 seconds ago
|
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
...overrides,
|
|
} as GameState);
|
|
|
|
const makePlayer = (overrides: Record<string, unknown> = {}) => ({
|
|
discordId: DISCORD_ID,
|
|
characterName: "T",
|
|
username: "u",
|
|
discriminator: "0",
|
|
avatar: null,
|
|
createdAt: Date.now(),
|
|
lastSavedAt: 0,
|
|
totalGoldEarned: 0,
|
|
totalClicks: 0,
|
|
lifetimeGoldEarned: 0,
|
|
lifetimeClicks: 0,
|
|
lifetimeBossesDefeated: 0,
|
|
lifetimeQuestsCompleted: 0,
|
|
lifetimeAdventurersRecruited: 0,
|
|
lifetimeAchievementsUnlocked: 0,
|
|
loginStreak: 1,
|
|
lastLoginDate: null,
|
|
unlockedTitles: null,
|
|
guildName: null,
|
|
...overrides,
|
|
});
|
|
|
|
describe("game route", () => {
|
|
let app: Hono;
|
|
let prisma: {
|
|
player: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
|
gameState: { findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
delete process.env["ANTI_CHEAT_SECRET"];
|
|
const { gameRouter } = await import("../../src/routes/game.js");
|
|
const { prisma: p } = await import("../../src/db/client.js");
|
|
prisma = p as typeof prisma;
|
|
app = new Hono();
|
|
app.route("/game", gameRouter);
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env["ANTI_CHEAT_SECRET"];
|
|
});
|
|
|
|
describe("GET /load", () => {
|
|
it("returns 404 when neither game state nor player exists", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("creates fresh state when game state is missing but player exists", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.create).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { offlineGold: number; schemaOutdated: boolean };
|
|
expect(body.offlineGold).toBe(0);
|
|
expect(body.schemaOutdated).toBe(false);
|
|
});
|
|
|
|
it("returns state with offline earnings when game state exists", async () => {
|
|
const state = makeState({ lastTickAt: Date.now() - 10_000 }); // 10 seconds ago
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never).mockRejectedValueOnce(Object.assign(new Error("conflict"), { code: "P2034" }));
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { state: GameState; offlineSeconds: number; currentSchemaVersion: number };
|
|
expect(body.currentSchemaVersion).toBe(CURRENT_SCHEMA_VERSION);
|
|
expect(typeof body.offlineSeconds).toBe("number");
|
|
});
|
|
|
|
it("awards login bonus when player logs in on a new day", async () => {
|
|
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: yesterday, loginStreak: 3 }) as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { loginBonus: { streak: number; goldEarned: number } | null };
|
|
expect(body.loginBonus).not.toBeNull();
|
|
expect(body.loginBonus?.streak).toBe(4);
|
|
});
|
|
|
|
it("resets streak when login gap is more than one day", async () => {
|
|
const longAgo = "2020-01-01";
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: longAgo, loginStreak: 10 }) as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { loginBonus: { streak: number } | null };
|
|
expect(body.loginBonus?.streak).toBe(1);
|
|
});
|
|
|
|
it("does not award login bonus when already logged in today", async () => {
|
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ lastLoginDate: todayUTC }) as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { loginBonus: null };
|
|
expect(body.loginBonus).toBeNull();
|
|
});
|
|
|
|
it("includes HMAC signature when ANTI_CHEAT_SECRET is set", async () => {
|
|
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { signature: string | undefined };
|
|
expect(typeof body.signature).toBe("string");
|
|
});
|
|
|
|
it("marks schema as outdated when save has older schema version", async () => {
|
|
const state = makeState({ schemaVersion: 0 });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { schemaOutdated: boolean };
|
|
expect(body.schemaOutdated).toBe(true);
|
|
});
|
|
|
|
it("returns non-zero offline earnings when adventurers have production stats", async () => {
|
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
|
const state = makeState({
|
|
adventurers: [{
|
|
id: "worker", count: 1, unlocked: true, level: 1,
|
|
goldPerSecond: 1, essencePerSecond: 1, combatPower: 0,
|
|
}] as GameState["adventurers"],
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ lastLoginDate: todayUTC }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { offlineGold: number; offlineEssence: number };
|
|
expect(body.offlineGold).toBeGreaterThan(0);
|
|
expect(body.offlineEssence).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("syncs updated avatar from Discord into the returned state", async () => {
|
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
|
});
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { state: GameState };
|
|
expect(body.state.player.avatar).toBe("new_hash");
|
|
});
|
|
|
|
it("continues loading when the avatar DB update fails", async () => {
|
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.player.update).mockRejectedValueOnce(new Error("db error"));
|
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
|
});
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("continues loading when the avatar DB update fails with a non-Error value", async () => {
|
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ lastLoginDate: todayUTC, avatar: "old_hash" }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.player.update).mockRejectedValueOnce("raw string error");
|
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce({
|
|
id: DISCORD_ID, username: "u", discriminator: "0", avatar: "new_hash",
|
|
});
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("keeps stored avatar when Discord returns null", async () => {
|
|
const todayUTC = new Date().toISOString().slice(0, 10);
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ lastLoginDate: todayUTC, avatar: "stored_hash" }) as never,
|
|
);
|
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
|
const { fetchDiscordUserById } = await import("../../src/services/discord.js");
|
|
vi.mocked(fetchDiscordUserById).mockResolvedValueOnce(null);
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { state: GameState };
|
|
expect(body.state.player.avatar).toBe("stored_hash");
|
|
});
|
|
});
|
|
|
|
describe("POST /save", () => {
|
|
const save = (body: Record<string, unknown>) =>
|
|
app.fetch(new Request("http://localhost/game/save", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}));
|
|
|
|
it("returns 400 when state is missing from body", async () => {
|
|
const res = await save({});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 409 when save schema version is outdated", async () => {
|
|
const state = makeState({ schemaVersion: 0 });
|
|
const res = await save({ state });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it("saves state when no previous record exists", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const state = makeState();
|
|
const res = await save({ state });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { savedAt: number };
|
|
expect(body.savedAt).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("falls back to state characterName when playerRecord is null", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce(null);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const state = makeState();
|
|
const res = await save({ state });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("validates and sanitizes state when previous record exists", async () => {
|
|
const prevState = makeState({ resources: { gold: 0, essence: 0, crystals: 0, runestones: 0 } });
|
|
const incomingState = makeState({ resources: { gold: 1e400, essence: 0, crystals: 0, runestones: 9999 } });
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await save({ state: incomingState });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("rejects save with wrong HMAC signature when secret is configured", async () => {
|
|
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
|
|
const prevState = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
const res = await save({ state: makeState(), signature: "wrong_signature" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("accepts save with correct HMAC signature", async () => {
|
|
process.env["ANTI_CHEAT_SECRET"] = "my_secret";
|
|
const { createHmac } = await import("node:crypto");
|
|
const prevState = makeState();
|
|
const correctSig = createHmac("sha256", "my_secret").update(JSON.stringify(prevState)).digest("hex");
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await save({ state: makeState(), signature: correctSig });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("unlocks new titles and persists them", async () => {
|
|
const prevState = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ guildName: "My Guild" }) as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await save({ state: makeState() });
|
|
expect(res.status).toBe(200);
|
|
// Just verifies the route completes without error when title checking runs
|
|
});
|
|
|
|
it("exercises all validateAndSanitize branches with rich state", async () => {
|
|
const now = Date.now();
|
|
const prevState = makeState({
|
|
resources: { gold: 1000, essence: 50, crystals: 5, runestones: 5 },
|
|
adventurers: [
|
|
{ id: "militia", count: 5, unlocked: true, level: 2, goldPerSecond: 0.5, essencePerSecond: 0, combatPower: 3 },
|
|
] as GameState["adventurers"],
|
|
upgrades: [
|
|
{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 },
|
|
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 1.5 },
|
|
] as GameState["upgrades"],
|
|
quests: [
|
|
// main path: active → completed (startedAt far in past → expired)
|
|
{ id: "first_steps", status: "active", startedAt: 1000 },
|
|
// defensive: prevQuest.status === "completed" → skip in computeQuestRewards
|
|
{ id: "goblin_camp", status: "completed", startedAt: 1000 },
|
|
// defensive: prevQuest.status !== "active" → skip
|
|
{ id: "haunted_mine", status: "locked", startedAt: null },
|
|
// defensive: startedAt == null → skip
|
|
{ id: "ancient_ruins", status: "active", startedAt: null },
|
|
// defensive: !questData → skip (not in DEFAULT_QUESTS)
|
|
{ id: "not_a_real_quest", status: "active", startedAt: 1000 },
|
|
// anti-rollback: completed in prev, active in incoming → quests.map restores completed
|
|
{ id: "rollback_quest", status: "completed", startedAt: 1000 },
|
|
] as GameState["quests"],
|
|
bosses: [
|
|
// main path in computeBossRewards: available → defeated
|
|
{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, damagePerSecond: 5, goldReward: 10000, essenceReward: 25, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
// defensive: prevBoss.status === "defeated" → skip
|
|
{ id: "lich_queen", status: "defeated", currentHp: 0, maxHp: 10000, damagePerSecond: 20, goldReward: 100000, essenceReward: 200, crystalReward: 10, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
// defensive: prevBoss.status === "locked" → skip
|
|
{ id: "forest_giant", status: "locked", currentHp: 35000, maxHp: 35000, damagePerSecond: 40, goldReward: 350000, essenceReward: 400, crystalReward: 20, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
// defensive: !bossData → skip (not in DEFAULT_BOSSES)
|
|
{ id: "not_a_real_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
// anti-rollback: defeated in prev, available in incoming → bosses.map restores defeated
|
|
{ id: "anti_rollback_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
] as GameState["bosses"],
|
|
achievements: [
|
|
{ id: "ach1", unlockedAt: 1000 }, // prev has unlockedAt → anti-rollback when incoming=null
|
|
{ id: "ach2", unlockedAt: null }, // prev null → future timestamp check → caught
|
|
{ id: "ach3", unlockedAt: null }, // prev null → legitimate past unlock → return a
|
|
] as GameState["achievements"],
|
|
exploration: {
|
|
areas: [],
|
|
materials: [{ materialId: "verdant_sap", quantity: 10 }],
|
|
craftedRecipeIds: ["haunted_mine_recipe"],
|
|
craftedGoldMultiplier: 2,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
transcendence: { count: 1, echoes: 10, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
|
apotheosis: { count: 2 },
|
|
story: { unlockedChapterIds: ["ch1"], completedChapters: [{ chapterId: "ch1", completedAt: 1000 }] },
|
|
});
|
|
|
|
const incomingState = makeState({
|
|
resources: { gold: 1e18, essence: 1e18, crystals: 5, runestones: 0 },
|
|
adventurers: [
|
|
{ id: "militia", count: 5, unlocked: true, level: 2, goldPerSecond: 0.5, essencePerSecond: 0, combatPower: 3 },
|
|
] as GameState["adventurers"],
|
|
upgrades: [
|
|
{ id: "global_1", purchased: true, unlocked: true, target: "global", multiplier: 2 },
|
|
{ id: "click_1", purchased: true, unlocked: true, target: "click", multiplier: 1.5 },
|
|
] as GameState["upgrades"],
|
|
quests: [
|
|
{ id: "first_steps", status: "completed", startedAt: 1000 }, // was active → now completed
|
|
{ id: "goblin_camp", status: "completed", startedAt: 1000 }, // both completed → skip
|
|
{ id: "haunted_mine", status: "completed", startedAt: null }, // prevStatus=locked → skip
|
|
{ id: "ancient_ruins", status: "completed", startedAt: null }, // startedAt=null → skip
|
|
{ id: "not_a_real_quest", status: "completed", startedAt: 1000 }, // !questData → skip
|
|
{ id: "rollback_quest", status: "active", startedAt: 1000 }, // anti-rollback → restored
|
|
{ id: "orphan_quest", status: "completed", startedAt: 1000 }, // !prevQuest → skip
|
|
{ id: "still_active_quest", status: "active", startedAt: 1000 }, // status !== completed → skip
|
|
] as GameState["quests"],
|
|
bosses: [
|
|
{ id: "troll_king", status: "defeated", currentHp: 0, maxHp: 1000, damagePerSecond: 5, goldReward: 10000, essenceReward: 25, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
{ id: "lich_queen", status: "defeated", currentHp: 0, maxHp: 10000, damagePerSecond: 20, goldReward: 100000, essenceReward: 200, crystalReward: 10, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
{ id: "forest_giant", status: "defeated", currentHp: 0, maxHp: 35000, damagePerSecond: 40, goldReward: 350000, essenceReward: 400, crystalReward: 20, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
{ id: "not_a_real_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
{ id: "anti_rollback_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
{ id: "orphan_boss", status: "defeated", currentHp: 0, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
{ id: "still_available_boss", status: "available", currentHp: 100, maxHp: 100, damagePerSecond: 1, goldReward: 0, essenceReward: 0, crystalReward: 0, upgradeRewards: [], equipmentRewards: [], prestigeRequirement: 0 },
|
|
] as GameState["bosses"],
|
|
achievements: [
|
|
{ id: "ach1", unlockedAt: null }, // prev had unlockedAt → anti-rollback restores it
|
|
{ id: "ach2", unlockedAt: now + 99999 }, // future timestamp → cheat caught
|
|
{ id: "ach3", unlockedAt: 1000 }, // past timestamp → legitimate unlock
|
|
{ id: "ach4", unlockedAt: null }, // not in prev → !prev → return a
|
|
] as GameState["achievements"],
|
|
exploration: {
|
|
areas: [],
|
|
materials: [{ materialId: "verdant_sap", quantity: 1000 }], // inflated → capped at 10
|
|
craftedRecipeIds: ["haunted_mine_recipe", "fake_recipe"], // fake_recipe filtered out
|
|
craftedGoldMultiplier: 1,
|
|
craftedEssenceMultiplier: 1,
|
|
craftedClickMultiplier: 1,
|
|
craftedCombatMultiplier: 1,
|
|
},
|
|
transcendence: { count: 1, echoes: 100, purchasedUpgradeIds: [], echoIncomeMultiplier: 1, echoCombatMultiplier: 1, echoPrestigeThresholdMultiplier: 1, echoPrestigeRunestoneMultiplier: 1, echoMetaMultiplier: 1 },
|
|
apotheosis: { count: 5 },
|
|
story: {
|
|
unlockedChapterIds: ["ch1", "ch2"],
|
|
completedChapters: [{ chapterId: "ch1", completedAt: 1000 }, { chapterId: "ch2", completedAt: now }],
|
|
},
|
|
});
|
|
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer({ createdAt: Date.now() }) as never);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await save({ state: incomingState });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { savedAt: number };
|
|
expect(body.savedAt).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("validates companion when active companion is legitimately unlocked", async () => {
|
|
const prevState = makeState();
|
|
const stateWithCompanion = makeState({
|
|
companions: { unlockedCompanionIds: [], activeCompanionId: "lyra" },
|
|
});
|
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state: prevState } as never);
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(
|
|
makePlayer({ lifetimeBossesDefeated: 100 }) as never,
|
|
);
|
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await save({ state: stateWithCompanion });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("GET /load error path", () => {
|
|
it("returns 500 when the database throws during load", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value during load", async () => {
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await app.fetch(new Request("http://localhost/game/load"));
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("POST /save error path", () => {
|
|
const save = (body: Record<string, unknown>) =>
|
|
app.fetch(new Request("http://localhost/game/save", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}));
|
|
|
|
it("returns 500 when the database throws during save", async () => {
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await save({ state });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value during save", async () => {
|
|
const state = makeState();
|
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await save({ state });
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe("POST /reset", () => {
|
|
const reset = () =>
|
|
app.fetch(new Request("http://localhost/game/reset", { method: "POST" }));
|
|
|
|
it("returns 404 when player is not found", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(null);
|
|
const res = await reset();
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("creates fresh state and returns it on success", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await reset();
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { offlineGold: number; schemaOutdated: boolean; loginBonus: null };
|
|
expect(body.offlineGold).toBe(0);
|
|
expect(body.schemaOutdated).toBe(false);
|
|
expect(body.loginBonus).toBeNull();
|
|
});
|
|
|
|
it("includes HMAC signature in reset response when secret is configured", async () => {
|
|
process.env["ANTI_CHEAT_SECRET"] = "reset_secret";
|
|
vi.mocked(prisma.player.findUnique).mockResolvedValueOnce(makePlayer() as never);
|
|
vi.mocked(prisma.gameState.upsert).mockResolvedValueOnce({} as never);
|
|
const res = await reset();
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as { signature: string | undefined };
|
|
expect(typeof body.signature).toBe("string");
|
|
});
|
|
|
|
it("returns 500 when the database throws during reset", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
|
const res = await reset();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
|
|
it("returns 500 when the database throws a non-Error value during reset", async () => {
|
|
vi.mocked(prisma.player.findUnique).mockRejectedValueOnce("raw string error");
|
|
const res = await reset();
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
});
|