/** * EOD custom minefield game rules. * * Deterministic, pure module for the custom-minefields minigame content: * validation for grid/mine input, score submission bounds, and the * custom-mode unlock predicate. * * Framework-free and client-safe: no React, Next.js, or Payload imports, * no side effects. */ export const MIN_SIZE = 9; export const MAX_SIZE = 500; export const MAX_MINES = 10000; export const MAX_SECONDS = 3600; export const MAX_NAME_LENGTH = 100; export type MinePosition = { row: number; col: number }; export interface EodUnlockStats { bestTrainingTime: number; bestPerimeterTime: number; bestSupplyRoadTime: number; } /** A player unlocks the custom minefield editor once they cleared all three standard difficulties. */ export function isEodUnlocked(stats: EodUnlockStats): boolean { return ( stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0 ); } export function validateMinefieldName(name: unknown): { error?: string; name: string } { const trimmed = typeof name === "string" ? name.trim() : ""; if (trimmed.length < 1 || trimmed.length > MAX_NAME_LENGTH) { return { error: "Name must be 1-100 characters.", name: "" }; } return { name: trimmed }; } export function validateGridSize( rows: unknown, cols: unknown, ): { error?: string; rows: number; cols: number } { if ( !Number.isInteger(rows) || !Number.isInteger(cols) || (rows as number) < MIN_SIZE || (rows as number) > MAX_SIZE || (cols as number) < MIN_SIZE || (cols as number) > MAX_SIZE ) { return { error: `Grid must be ${MIN_SIZE}×${MIN_SIZE} to ${MAX_SIZE}×${MAX_SIZE}.`, rows: 0, cols: 0 }; } return { rows: rows as number, cols: cols as number }; } export function validateMinePositions( input: unknown, rows: number, cols: number, ): { error?: string; positions: MinePosition[] } { if (!Array.isArray(input)) { return { error: "Mines must be an array of positions.", positions: [] }; } if (input.length < 1 || input.length > MAX_MINES) { return { error: `Mine count must be between 1 and ${MAX_MINES}.`, positions: [] }; } if (input.length > rows * cols) { return { error: `Mine count exceeds the ${rows}×${cols} grid.`, positions: [], }; } const seen = new Set(); const positions: MinePosition[] = []; for (const entry of input) { const candidate = entry as MinePosition | null | undefined; const row = candidate?.row; const col = candidate?.col; if ( !Number.isInteger(row) || !Number.isInteger(col) || (row as number) < 0 || (row as number) >= rows || (col as number) < 0 || (col as number) >= cols ) { return { error: `Every mine needs a row (0-${rows - 1}) and column (0-${cols - 1}).`, positions: [], }; } const key = (row as number) * cols + (col as number); if (seen.has(key)) { return { error: "Each cell can only hold one mine.", positions: [] }; } seen.add(key); positions.push({ row: row as number, col: col as number }); } return { positions }; } export interface MinefieldScoreInput { seconds: number; flags: number; } export function validateMinefieldScore(input: MinefieldScoreInput): { error?: string; seconds: number; flags: number; } { if ( !Number.isFinite(input.seconds) || !Number.isInteger(input.seconds) || input.seconds <= 0 || input.seconds > MAX_SECONDS ) { return { error: "Invalid result.", seconds: 0, flags: 0 }; } if (!Number.isInteger(input.flags) || input.flags < 0) { return { error: "Invalid result.", seconds: 0, flags: 0 }; } return { seconds: input.seconds, flags: input.flags }; } /** Flags above the declared mine count are impossible: catch them before recording. */ export function minefieldFlagsError(flags: number, mineCount: number): string | undefined { if (flags > mineCount) { return "Invalid result."; } return undefined; } // --------------------------------------------------------------------------- // XP payouts // // Custom minefields pay a small share of the standard payout curve so community // content stays far below official play: players earn PLAYER_XP_RATE of what // the equivalent standard run would pay, and the field's author banks // CREATOR_XP_RATE of each player payout. Authors earn nothing on their own // fields. Numbers stay intentionally tiny. // --------------------------------------------------------------------------- export const PLAYER_XP_RATE = 0.1; export const CREATOR_XP_RATE = 0.05; export const XP_SCORE_DIVISOR = 50; export const TRAINING_BRACKET_MAX_MINES = 15; export const PERIMETER_BRACKET_MAX_MINES = 50; export interface CustomXpBracket { baseScore: number; parSeconds: number; } /** * Reference standard difficulty for a custom field, chosen by mine count. * Brackets mirror the standard EOD difficulties (see DIFFICULTIES in the EOD * actions): Training Pad 9x9/10, FOB Perimeter 16x16/40, Main Supply Road 16x30/99. */ export function customXpBracket(mineCount: number): CustomXpBracket { if (mineCount <= TRAINING_BRACKET_MAX_MINES) { return { baseScore: 100, parSeconds: 60 }; } if (mineCount <= PERIMETER_BRACKET_MAX_MINES) { return { baseScore: 300, parSeconds: 180 }; } return { baseScore: 600, parSeconds: 420 }; } /** Player XP for a custom run. Losses pay nothing; wins pay 1-2 points. */ export function customPlayerXp(input: { mineCount: number; seconds: number; won: boolean; }): number { if (!input.won) { return 0; } const bracket = customXpBracket(input.mineCount); const score = bracket.baseScore + Math.max(0, bracket.parSeconds - input.seconds); const regularXp = Math.max(1, Math.floor(score / XP_SCORE_DIVISOR)); return Math.max(1, Math.floor(PLAYER_XP_RATE * regularXp)); } /** The author's fractional bank grows by this much per player payout. */ export function creatorBankIncrement(playerXp: number): number { return CREATOR_XP_RATE * playerXp; } /** * Adds a payout to the author's fractional bank and reports how many whole XP * points are due. Whole points credit when the bank crosses 1, so a 5% share * of 1-2 XP payouts still materializes over time. */ export function applyCreatorBank( currentBank: number, playerXp: number, ): { bank: number; wholeXpToCredit: number } { const bank = currentBank + creatorBankIncrement(playerXp); const wholeXpToCredit = Math.floor(bank); return { bank: bank - wholeXpToCredit, wholeXpToCredit }; } // --------------------------------------------------------------------------- // Loss reveal // --------------------------------------------------------------------------- /** * Mines that touch the exposed (revealed) area of a lost run, as linear * indices. Losses reveal only these frontier mines (the caller keeps the * exploded mine revealed too); the rest of the layout stays hidden because * the field is static and a full reveal would leak the solution. */ export function edgeMineIndices(input: { rows: number; cols: number; mines: number[]; revealed: number[]; }): number[] { const revealedSet = new Set(input.revealed); const frontier: number[] = []; for (const mine of input.mines) { const mineRow = Math.floor(mine / input.cols); const mineCol = mine % input.cols; let touchesExposedArea = false; for (let rowOffset = -1; rowOffset <= 1 && !touchesExposedArea; rowOffset++) { for (let colOffset = -1; colOffset <= 1 && !touchesExposedArea; colOffset++) { if (rowOffset === 0 && colOffset === 0) continue; const neighborRow = mineRow + rowOffset; const neighborCol = mineCol + colOffset; if ( neighborRow < 0 || neighborRow >= input.rows || neighborCol < 0 || neighborCol >= input.cols ) { continue; } if (revealedSet.has(neighborRow * input.cols + neighborCol)) { touchesExposedArea = true; } } } if (touchesExposedArea) { frontier.push(mine); } } return frontier; }