import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Role, User } from "@/payload-types"; import { CustomMinefields } from "@/collections/minigames/CustomMinefields"; import { hasPermission } from "@/utils/access-control/hasPermission"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; import { applyCreatorBank, creatorBankIncrement, customPlayerXp, customXpBracket, edgeMineIndices, isEodUnlocked, minefieldFlagsError, validateGridSize, validateMinefieldName, validateMinefieldScore, validateMinePositions, } from "@/lib/minigames/custom/eod"; import { creditEodXp } from "@/lib/minigames/custom/payouts"; let payload: Payload; const RUN = `cmf-${Date.now().toString(36)}`; const TIMEOUT = 30_000; /** Minimal valid mine positions for a 9x9 grid. */ const validMines = [ { row: 0, col: 0 }, { row: 2, col: 2 }, { row: 8, col: 8 }, ]; describe("Custom minefields", () => { let author: User; let authorId: number; const minefieldIds: number[] = []; const scoreIds: number[] = []; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); author = (await payload.create({ collection: "users", data: { username: `${RUN}-author`, discordUsername: `${RUN}-author`, displayName: "MINEFIELD AUTHOR", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; authorId = author.id; }); afterAll(async () => { // Delete scores first (FK → minefield) for (const id of scoreIds) { await payload .delete({ collection: "custom-minefield-scores", id, overrideAccess: true }) .catch(() => {}); } // Delete minefields for (const id of minefieldIds) { await payload .delete({ collection: "custom-minefields", id, overrideAccess: true }) .catch(() => {}); } // Delete user (and rely on afterChange hook cleanup for profile/bank) await payload .delete({ collection: "users", id: authorId, overrideAccess: true }) .catch(() => {}); }); // --------------------------------------------------------------------------- // Collection-level field constraints // --------------------------------------------------------------------------- describe("Collection field validation", () => { it("creates a valid minefield with all required fields", async () => { const doc = await payload.create({ collection: "custom-minefields", data: { name: "Test Minefield", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, published: true, }, overrideAccess: true, depth: 0, }); minefieldIds.push(doc.id); expect(doc.name).toBe("Test Minefield"); expect(doc.rows).toBe(9); expect(doc.cols).toBe(9); expect(doc.mineCount).toBe(3); expect(doc.published).toBe(true); expect(doc.playCount).toBe(0); }); it("rejects rows below minimum (8 < 9)", async () => { await expect( payload.create({ collection: "custom-minefields", data: { name: "Tiny", author: authorId, rows: 8, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects cols above maximum (501 > 500)", async () => { await expect( payload.create({ collection: "custom-minefields", data: { name: "Wide", author: authorId, rows: 9, cols: 501, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects mineCount of 0", async () => { await expect( payload.create({ collection: "custom-minefields", data: { name: "No Mines", author: authorId, rows: 9, cols: 9, mines: [], mineCount: 0, }, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("defaults published to true and playCount to 0", async () => { const doc = await payload.create({ collection: "custom-minefields", data: { name: "Defaults Test", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); minefieldIds.push(doc.id); expect(doc.published).toBe(true); expect(doc.playCount).toBe(0); }); it("rejects a minefield without a name", async () => { await expect( payload.create({ collection: "custom-minefields", data: { author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects a minefield without an author", async () => { await expect( payload.create({ collection: "custom-minefields", data: { name: "Orphan", rows: 9, cols: 9, mines: validMines, mineCount: 3, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); }); // --------------------------------------------------------------------------- // Score collection // --------------------------------------------------------------------------- describe("Custom minefield scores", () => { let minefieldId: number; beforeAll(async () => { const doc = await payload.create({ collection: "custom-minefields", data: { name: "Score Test Field", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); minefieldId = doc.id; minefieldIds.push(minefieldId); }); it("creates a score record with valid fields", async () => { const score = await payload.create({ collection: "custom-minefield-scores", data: { minefield: minefieldId, user: authorId, seconds: 45, flags: 3, won: true, }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); expect(score.minefield).toBe(minefieldId); expect(score.user).toBe(authorId); expect(score.seconds).toBe(45); expect(score.flags).toBe(3); expect(score.won).toBe(true); }); it("rejects a score without a minefield reference", async () => { await expect( payload.create({ collection: "custom-minefield-scores", data: { user: authorId, seconds: 10, flags: 0, won: false, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("rejects a score without a user reference", async () => { await expect( payload.create({ collection: "custom-minefield-scores", data: { minefield: minefieldId, seconds: 10, flags: 0, won: false, } as any, overrideAccess: true, depth: 0, }), ).rejects.toThrow(); }); it("creates a losing score with flags=0", async () => { const score = await payload.create({ collection: "custom-minefield-scores", data: { minefield: minefieldId, user: authorId, seconds: 120, flags: 0, won: false, }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); expect(score.won).toBe(false); expect(score.flags).toBe(0); }); }); // --------------------------------------------------------------------------- // Access control // --------------------------------------------------------------------------- describe("Access control", () => { let viewer: User; let viewerId: number; let viewerMinefieldId: number; beforeAll(async () => { viewer = (await payload.create({ collection: "users", data: { username: `${RUN}-viewer`, discordUsername: `${RUN}-viewer`, displayName: "VIEWER", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; viewerId = viewer.id; const doc = await payload.create({ collection: "custom-minefields", data: { name: "Viewer Minefield", author: viewerId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); viewerMinefieldId = doc.id; minefieldIds.push(viewerMinefieldId); }); it("logged-in user can read published minefields", async () => { const result = await payload.find({ collection: "custom-minefields", where: { published: { equals: true } }, limit: 1, overrideAccess: true, depth: 0, }); expect(result.docs.length).toBeGreaterThanOrEqual(1); }); it("logged-in user can create a minefield", async () => { const doc = await payload.create({ collection: "custom-minefields", data: { name: "Can Create", author: viewerId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); minefieldIds.push(doc.id); expect(doc.name).toBe("Can Create"); }); it("author can update their own minefield name via overrideAccess", async () => { const updated = await payload.update({ collection: "custom-minefields", id: viewerMinefieldId, data: { name: "Updated Name" }, overrideAccess: true, depth: 0, }); expect(updated.name).toBe("Updated Name"); }); it("published minefields are visible to all logged-in users", async () => { const result = await payload.find({ collection: "custom-minefields", where: { published: { equals: true } }, limit: 20, overrideAccess: true, depth: 0, }); expect(result.docs.some((doc) => doc.name === "Updated Name")).toBe(true); }); }); // --------------------------------------------------------------------------- // Delete flow // --------------------------------------------------------------------------- describe("Delete flow", () => { it("deleting a minefield's scores then the minefield cleans up cleanly", async () => { const mf = await payload.create({ collection: "custom-minefields", data: { name: "Delete Test", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); const score = await payload.create({ collection: "custom-minefield-scores", data: { minefield: mf.id, user: authorId, seconds: 25, flags: 1, won: true, }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); await payload.delete({ collection: "custom-minefield-scores", id: score.id, overrideAccess: true, }); await payload.delete({ collection: "custom-minefields", id: mf.id, overrideAccess: true, }); const remaining = await payload.findByID({ collection: "custom-minefield-scores", id: score.id, overrideAccess: true, depth: 0, }).catch(() => null); expect(remaining).toBeNull(); }); }); // --------------------------------------------------------------------------- // Unlock gating (pure lib: mirrors checkEodUnlock) // --------------------------------------------------------------------------- describe("Unlock gating", () => { it("locks custom minefields until Training Pad and FOB Perimeter are cleared", () => { expect( isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }), ).toBe(false); expect( isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 0, bestSupplyRoadTime: 0 }), ).toBe(false); expect( isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }), ).toBe(false); }); it("unlocks once Training Pad and FOB Perimeter have best times above zero", () => { expect( isEodUnlocked({ bestTrainingTime: 1, bestPerimeterTime: 1, bestSupplyRoadTime: 0 }), ).toBe(true); expect( isEodUnlocked({ bestTrainingTime: 45, bestPerimeterTime: 60, bestSupplyRoadTime: 0 }), ).toBe(true); }); it("does not let a Main Supply Road win substitute for the required sectors", () => { expect( isEodUnlocked({ bestTrainingTime: 0, bestPerimeterTime: 0, bestSupplyRoadTime: 120 }), ).toBe(false); }); }); // --------------------------------------------------------------------------- // Action-level validation (pure lib: mirrors createMinefield / updateMinefield // / submitCustomMinefieldScore) // --------------------------------------------------------------------------- describe("Action-level validation", () => { it("rejects empty, oversized, and non-string names; trims valid ones", () => { expect(validateMinefieldName("").error).toBe("Name must be 1-100 characters."); expect(validateMinefieldName(" ").error).toBe("Name must be 1-100 characters."); expect(validateMinefieldName("x".repeat(101)).error).toBe("Name must be 1-100 characters."); expect(validateMinefieldName(123).error).toBe("Name must be 1-100 characters."); expect(validateMinefieldName(" Clear Path ")).toEqual({ name: "Clear Path" }); }); it("rejects grid sizes outside 9-500 on either axis and non-integers", () => { expect(validateGridSize(8, 9).error).toBe("Grid must be 9×9 to 500×500."); expect(validateGridSize(9, 501).error).toBe("Grid must be 9×9 to 500×500."); expect(validateGridSize(9.5, 9).error).toBe("Grid must be 9×9 to 500×500."); expect(validateGridSize("9", 9).error).toBe("Grid must be 9×9 to 500×500."); expect(validateGridSize(9, 9)).toEqual({ rows: 9, cols: 9 }); expect(validateGridSize(500, 500)).toEqual({ rows: 500, cols: 500 }); }); it("rejects non-array, empty, oversized, out-of-range, and duplicate mine lists", () => { expect(validateMinePositions("boom", 9, 9).error).toBe("Mines must be an array of positions."); expect(validateMinePositions([], 9, 9).error).toBe("Mine count must be between 1 and 10000."); const tooMany = Array.from({ length: 10001 }, (_, i) => ({ row: 0, col: i % 9 })); expect(validateMinePositions(tooMany, 9, 9).error).toBe( "Mine count must be between 1 and 10000.", ); const overflow = Array.from({ length: 82 }, (_, i) => ({ row: Math.floor(i / 9), col: i % 9, })); expect(validateMinePositions(overflow, 9, 9).error).toBe("Mine count exceeds the 9×9 grid."); expect(validateMinePositions([{ row: 9, col: 0 }], 9, 9).error).toBe( "Every mine needs a row (0-8) and column (0-8).", ); expect(validateMinePositions([{ row: 0, col: -1 }], 9, 9).error).toBe( "Every mine needs a row (0-8) and column (0-8).", ); expect(validateMinePositions([{ row: 0.5, col: 0 }], 9, 9).error).toBe( "Every mine needs a row (0-8) and column (0-8).", ); expect( validateMinePositions( [ { row: 0, col: 0 }, { row: 0, col: 0 }, ], 9, 9, ).error, ).toBe("Each cell can only hold one mine."); }); it("normalizes valid mine positions", () => { const result = validateMinePositions(validMines, 9, 9); expect(result.error).toBeUndefined(); expect(result.positions).toEqual(validMines); }); it("rejects impossible score results", () => { expect(validateMinefieldScore({ seconds: 0, flags: 0 }).error).toBe("Invalid result."); expect(validateMinefieldScore({ seconds: -5, flags: 0 }).error).toBe("Invalid result."); expect(validateMinefieldScore({ seconds: 2.5, flags: 0 }).error).toBe("Invalid result."); expect(validateMinefieldScore({ seconds: 3601, flags: 0 }).error).toBe("Invalid result."); expect(validateMinefieldScore({ seconds: 30, flags: -1 }).error).toBe("Invalid result."); expect(validateMinefieldScore({ seconds: Number.NaN, flags: 0 }).error).toBe( "Invalid result.", ); expect(validateMinefieldScore({ seconds: 30, flags: 2 })).toEqual({ seconds: 30, flags: 2 }); expect(validateMinefieldScore({ seconds: 3600, flags: 0 })).toEqual({ seconds: 3600, flags: 0, }); }); it("rejects flag counts above the minefield's declared mine count", () => { expect(minefieldFlagsError(4, 3)).toBe("Invalid result."); expect(minefieldFlagsError(3, 3)).toBeUndefined(); expect(minefieldFlagsError(0, 3)).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // Leaderboard ordering (DB: replicates CustomMinefieldLeaderboard's query: // where minefield + won=true, sort seconds,flags, best won time per player) // --------------------------------------------------------------------------- describe("Leaderboard ordering", () => { let fieldA: number; let fieldB: number; const tempUserIds: number[] = []; const makePlayer = async (label: string): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-lb-${label}`, discordUsername: `${RUN}-lb-${label}`, displayName: `LBOARD ${label.toUpperCase()}`, steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; tempUserIds.push(user.id); return user.id; }; const record = async ( minefieldId: number, userId: number, seconds: number, flags: number, won: boolean, ) => { const score = await payload.create({ collection: "custom-minefield-scores", data: { minefield: minefieldId, user: userId, seconds, flags, won }, overrideAccess: true, depth: 0, }); scoreIds.push(score.id); return score.id; }; beforeAll(async () => { const a = await payload.create({ collection: "custom-minefields", data: { name: "LBoard Alpha", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); const b = await payload.create({ collection: "custom-minefields", data: { name: "LBoard Bravo", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); fieldA = a.id; fieldB = b.id; minefieldIds.push(fieldA, fieldB); }, TIMEOUT); afterAll(async () => { if (!payload) return; // Runs before the parent afterAll: remove each temp user's scores first // (FK), then the users. Leftover ids in scoreIds are cleaned up (and // tolerated) by the parent afterAll. for (const uid of tempUserIds) { await payload .delete({ collection: "custom-minefield-scores", where: { user: { equals: uid } }, overrideAccess: true, }) .catch(() => {}); await payload .delete({ collection: "users", id: uid, overrideAccess: true }) .catch(() => {}); } }); it("keeps each player's best won time and isolates leaderboards per minefield", async () => { const u1 = await makePlayer("one"); const u2 = await makePlayer("two"); // Field A: u1 40s then improved 35s, u2 30s, plus a faster-but-lost run. await record(fieldA, u1, 40, 2, true); await record(fieldA, u1, 35, 2, true); await record(fieldA, u2, 30, 1, true); await record(fieldA, u1, 5, 0, false); // Field B: only u1 plays (50s). await record(fieldB, u1, 50, 1, true); const leaderboardFor = async (minefieldId: number) => { const res = await payload.find({ collection: "custom-minefield-scores", where: { minefield: { equals: minefieldId }, won: { equals: true } }, sort: "seconds,flags", limit: 100, depth: 1, overrideAccess: true, }); const best = new Map(); for (const doc of res.docs) { const ref = doc.user as unknown as { id: number } | number | null; if (ref === null) continue; const uid = typeof ref === "object" ? ref.id : ref; if (typeof uid !== "number") continue; const prev = best.get(uid); if (!prev || doc.seconds < prev.seconds) { best.set(uid, { seconds: doc.seconds, flags: doc.flags ?? 0 }); } } return Array.from(best.entries()) .map(([uid, v]) => ({ uid, ...v })) .sort((x, y) => x.seconds - y.seconds || x.flags - y.flags); }; const ordered = await leaderboardFor(fieldA); expect(ordered).toHaveLength(2); // u2's 30s beats u1's best won 35s; the 5s loss never counts. expect(ordered[0].uid).toBe(u2); expect(ordered[0].seconds).toBe(30); expect(ordered[1].uid).toBe(u1); expect(ordered[1].seconds).toBe(35); expect(ordered[1].flags).toBe(2); const orderedB = await leaderboardFor(fieldB); expect(orderedB).toHaveLength(1); expect(orderedB[0].uid).toBe(u1); expect(orderedB[0].seconds).toBe(50); }, TIMEOUT); }); // --------------------------------------------------------------------------- // Edit and delete permissions (action gate + collection access) // --------------------------------------------------------------------------- describe("Edit and delete permissions", () => { const roleIds: number[] = []; const permUserIds: number[] = []; let otherPlayer: User; let manager: User; let devUser: User; let gateFieldId: number; const makeUserWithRole = async (label: string, roleId: number): Promise => { const user = (await payload.create({ collection: "users", data: { username: `${RUN}-perm-${label}`, discordUsername: `${RUN}-perm-${label}`, displayName: `PERM ${label.toUpperCase()}`, steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", // Permission resolution reads roleDocs (dynamic RBAC), not the legacy // `roles` enum: assign a real role doc to grant permissions. roleDocs: [roleId], }, overrideAccess: true, depth: 0, })) as unknown as User; permUserIds.push(user.id); return user; }; const invokeAccess = (fn: unknown, user: User | null): Promise => { return (fn as (args: { req: unknown }) => Promise)({ req: { user, payload }, }); }; beforeAll(async () => { const plainRole = (await payload.create({ collection: "roles", data: { name: `${RUN}-plain`, slug: `${RUN}-plain`, permissions: [] }, overrideAccess: true, depth: 0, })) as unknown as Role; const managerRole = (await payload.create({ collection: "roles", data: { name: `${RUN}-mf-manager`, slug: `${RUN}-mf-manager`, permissions: ["custom-minefields:update"], }, overrideAccess: true, depth: 0, })) as unknown as Role; const devRole = (await payload.create({ collection: "roles", data: { name: `${RUN}-mf-dev`, slug: `${RUN}-mf-dev`, isSuperuser: true }, overrideAccess: true, depth: 0, })) as unknown as Role; roleIds.push(plainRole.id, managerRole.id, devRole.id); invalidatePermissionCache(); otherPlayer = await makeUserWithRole("other", plainRole.id); manager = await makeUserWithRole("manager", managerRole.id); devUser = await makeUserWithRole("dev", devRole.id); const gateField = await payload.create({ collection: "custom-minefields", data: { name: "Gate Field", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, }, overrideAccess: true, depth: 0, }); gateFieldId = gateField.id; minefieldIds.push(gateFieldId); }, TIMEOUT); afterAll(async () => { if (!payload) return; // Runs before the parent afterAll: profiles first (FK), then users, then roles. for (const uid of permUserIds) { const profiles = await payload .find({ collection: "profiles", where: { user: { equals: uid } }, limit: 5, depth: 0, overrideAccess: true, }) .catch(() => null); for (const p of profiles?.docs ?? []) { await payload .delete({ collection: "profiles", id: p.id, overrideAccess: true }) .catch(() => {}); } await payload .delete({ collection: "users", id: uid, overrideAccess: true }) .catch(() => {}); } for (const id of roleIds) { await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); } }); it("lets only the author (or a permissioned manager) pass the action's edit gate", async () => { // Mirrors updateMinefield: isManager || isAuthor, evaluated against the // gate field authored by `author`. const gate = async (user: User) => { const isManager = await hasPermission(payload, user, "custom-minefields:update"); return isManager || user.id === authorId; }; expect(await gate(author)).toBe(true); expect(await gate(otherPlayer)).toBe(false); expect(await gate(manager)).toBe(true); expect(await gate(devUser)).toBe(true); }, TIMEOUT); it("scopes collection update access to the author for non-managers", async () => { const fn = CustomMinefields.access?.update; expect(typeof fn).toBe("function"); expect(await invokeAccess(fn, author)).toMatchObject({ author: { equals: authorId } }); expect(await invokeAccess(fn, otherPlayer)).toMatchObject({ author: { equals: otherPlayer.id }, }); expect(await invokeAccess(fn, manager)).toBe(true); expect(await invokeAccess(fn, null)).toBe(false); }, TIMEOUT); it("gates deletes behind custom-minefields:delete", async () => { const fn = CustomMinefields.access?.delete; expect(typeof fn).toBe("function"); expect(await invokeAccess(fn, null)).toBe(false); expect(await invokeAccess(fn, author)).toBe(false); expect(await invokeAccess(fn, otherPlayer)).toBe(false); expect(await invokeAccess(fn, devUser)).toBe(true); }, TIMEOUT); it("gates publish (create) and read behind login", async () => { const createFn = CustomMinefields.access?.create; const readFn = CustomMinefields.access?.read; expect(typeof createFn).toBe("function"); expect(typeof readFn).toBe("function"); expect(await invokeAccess(createFn, null)).toBe(false); expect(await invokeAccess(createFn, otherPlayer)).toBe(true); expect(await invokeAccess(readFn, null)).toBe(false); expect(await invokeAccess(readFn, otherPlayer)).toBe(true); }, TIMEOUT); }); // --------------------------------------------------------------------------- // Custom XP payouts, loss reveal rules, and exposure toggle // --------------------------------------------------------------------------- describe("Custom XP payouts and loss reveal rules", () => { describe("payout math", () => { it("maps mine counts to standard difficulty brackets", () => { expect(customXpBracket(10)).toEqual({ baseScore: 100, parSeconds: 60 }); expect(customXpBracket(15)).toEqual({ baseScore: 100, parSeconds: 60 }); expect(customXpBracket(16)).toEqual({ baseScore: 300, parSeconds: 180 }); expect(customXpBracket(50)).toEqual({ baseScore: 300, parSeconds: 180 }); expect(customXpBracket(51)).toEqual({ baseScore: 600, parSeconds: 420 }); }); it("pays nothing on losses", () => { expect(customPlayerXp({ mineCount: 10, seconds: 30, won: false })).toBe(0); }); it("pays 1 XP on training-bracket wins regardless of pace", () => { expect(customPlayerXp({ mineCount: 10, seconds: 60, won: true })).toBe(1); expect(customPlayerXp({ mineCount: 10, seconds: 1, won: true })).toBe(1); }); it("pays 2 XP only on fast supply-road-bracket clears", () => { expect(customPlayerXp({ mineCount: 100, seconds: 1, won: true })).toBe(2); expect(customPlayerXp({ mineCount: 100, seconds: 420, won: true })).toBe(1); expect(customPlayerXp({ mineCount: 60, seconds: 120, won: true })).toBe(1); }); it("banks the creator share fractionally and credits whole points at 1", () => { const first = applyCreatorBank(0, 1); expect(first.bank).toBeCloseTo(0.05); expect(first.wholeXpToCredit).toBe(0); const second = applyCreatorBank(0.96, 1); expect(second.bank).toBeCloseTo(0.01); expect(second.wholeXpToCredit).toBe(1); const third = applyCreatorBank(1.98, 2); expect(third.bank).toBeCloseTo(0.08); expect(third.wholeXpToCredit).toBe(2); expect(creatorBankIncrement(2)).toBeCloseTo(0.1); }); }); describe("edge-mine loss reveal", () => { it("returns nothing when nothing is exposed", () => { expect( edgeMineIndices({ rows: 9, cols: 9, mines: [0, 40, 80], revealed: [] }), ).toEqual([]); }); it("picks mines adjacent to the exposed area, diagonals included", () => { // 9x9: revealed cell index 1 (row 0 col 1); mine at 0 (row 0 col 0) touches it. expect(edgeMineIndices({ rows: 9, cols: 9, mines: [0], revealed: [1] })).toEqual([0]); // Mine at 40 (row 4 col 4) touches revealed 48 (row 5 col 3) diagonally. expect(edgeMineIndices({ rows: 9, cols: 9, mines: [40], revealed: [48] })).toEqual([40]); // Mine at 80 (row 8 col 8) is far from the exposed area and stays hidden. expect(edgeMineIndices({ rows: 9, cols: 9, mines: [40, 80], revealed: [48] })).toEqual([ 40, ]); }); }); describe("exposure rule", () => { it("defaults exposeEdgeMines to true on new minefields", async () => { const field = await payload.create({ collection: "custom-minefields", data: { name: "Exposure Default", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3 }, overrideAccess: true, depth: 0, }); minefieldIds.push(field.id); expect(field.exposeEdgeMines).toBe(true); }, TIMEOUT); it("persists an author-chosen exposure rule of false", async () => { const field = await payload.create({ collection: "custom-minefields", data: { name: "Exposure Off", author: authorId, rows: 9, cols: 9, mines: validMines, mineCount: 3, exposeEdgeMines: false, }, overrideAccess: true, depth: 0, }); minefieldIds.push(field.id); expect(field.exposeEdgeMines).toBe(false); }, TIMEOUT); }); describe("profile credit", () => { let xpPlayer: User; beforeAll(async () => { xpPlayer = (await payload.create({ collection: "users", data: { username: `${RUN}-xp-player`, discordUsername: `${RUN}-xp-player`, displayName: "XP PLAYER", steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles: ["user"], }, overrideAccess: true, depth: 0, })) as unknown as User; }); afterAll(async () => { await payload .delete({ collection: "profiles", where: { user: { equals: xpPlayer.id } }, overrideAccess: true }) .catch(() => {}); await payload.delete({ collection: "users", id: xpPlayer.id, overrideAccess: true }).catch(() => {}); }); it("credits EOD minigame XP cumulatively on the profile", async () => { const first = await creditEodXp(payload, xpPlayer.id, 1); expect(first).not.toBeNull(); expect(first?.xpEarned).toBe(1); const second = await creditEodXp(payload, xpPlayer.id, 1); expect(second?.xpEarned).toBe(2); expect(second?.profileId).toBe(first?.profileId); const profileRes = await payload.find({ collection: "profiles", where: { user: { equals: xpPlayer.id } }, limit: 1, depth: 0, overrideAccess: true, }); const profile = profileRes.docs[0]; expect(profile).toBeDefined(); expect(profile?.progression?.minigames?.eodMinesweeper?.xpEarned).toBe(2); }, TIMEOUT); it("returns null for users without a profile", async () => { // User id 999999999 has no profile doc in this database. expect(await creditEodXp(payload, 999999999, 1)).toBeNull(); }, TIMEOUT); }); }); });