diff --git a/src/lib/custom-minigames/eod.ts b/src/lib/custom-minigames/eod.ts new file mode 100644 index 0000000..f4734bc --- /dev/null +++ b/src/lib/custom-minigames/eod.ts @@ -0,0 +1,136 @@ +/** + * 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; +} \ No newline at end of file diff --git a/src/lib/custom-minigames/radio.ts b/src/lib/custom-minigames/radio.ts new file mode 100644 index 0000000..cfbd880 --- /dev/null +++ b/src/lib/custom-minigames/radio.ts @@ -0,0 +1,131 @@ +/** + * Custom radio test game rules. + * + * Deterministic, pure module for the custom-radio-tests minigame content: + * validation for message pools / order / score submissions and the + * custom-mode unlock predicate. + * + * Framework-free and client-safe: no React, Next.js, or Payload imports, + * no side effects. + */ + +export const MIN_MESSAGES = 1; +export const MAX_MESSAGES = 100; +export const MAX_MESSAGE_LENGTH = 500; +export const MAX_SCORE_PER_GAME = 5000; +export const MAX_SPRINT_TIME_MS = 600_000; +export const SPRINT_ACCURACY_REQUIREMENT = 0.9; +export const WATCH_ACCURACY_REQUIREMENT = 0.85; +export const MAX_NAME_LENGTH = 100; + +export type RadioMode = "sprint" | "watch"; + +export function isRadioMode(value: string): value is RadioMode { + return value === "sprint" || value === "watch"; +} + +export type RadioOrder = "fixed" | "shuffled"; + +export function isValidRadioOrder(value: string): value is RadioOrder { + return value === "fixed" || value === "shuffled"; +} + +export interface RadioUnlockStats { + sprintBestAccuracy: number; + watchBestAccuracy: number; +} + +/** A player unlocks the custom radio test editor once they hit both accuracy requirements. */ +export function isRadioUnlocked(stats: RadioUnlockStats): boolean { + return ( + stats.sprintBestAccuracy >= SPRINT_ACCURACY_REQUIREMENT && + stats.watchBestAccuracy >= WATCH_ACCURACY_REQUIREMENT + ); +} + +export function validateTestName(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 validateMessages(input: unknown): { error?: string; messages: string[] } { + if (!Array.isArray(input)) { + return { error: "Messages must be a list of strings.", messages: [] }; + } + if (input.length < MIN_MESSAGES || input.length > MAX_MESSAGES) { + return { + error: `Message pool must contain ${MIN_MESSAGES}-${MAX_MESSAGES} messages.`, + messages: [], + }; + } + const messages: string[] = []; + for (const entry of input) { + if (typeof entry !== "string") { + return { error: "Every message must be a string.", messages: [] }; + } + const trimmed = entry.trim(); + if (trimmed.length < 1 || trimmed.length > MAX_MESSAGE_LENGTH) { + return { + error: `Messages must be 1-${MAX_MESSAGE_LENGTH} characters.`, + messages: [], + }; + } + messages.push(trimmed); + } + return { messages }; +} + +export interface RadioScoreInput { + score: number; + correctMessages: number; + incorrectMessages: number; + accuracy: number; + mode: string; + timeMs?: number; +} + +export type RadioScoreValidation = + | { error: string; score: number; mode: null; timeMs: null } + | { error?: undefined; score: number; mode: RadioMode; timeMs: number | null }; + +export function validateRadioScore(input: RadioScoreInput): RadioScoreValidation { + const invalid: RadioScoreValidation = { error: "Invalid result.", score: 0, mode: null, timeMs: null }; + if (!isRadioMode(input.mode)) { + return invalid; + } + const score = Math.floor(input.score); + if (!Number.isFinite(score) || score < 0 || score > MAX_SCORE_PER_GAME) { + return { error: "Invalid score.", score: 0, mode: null, timeMs: null }; + } + if ( + !Number.isInteger(input.correctMessages) || + input.correctMessages < 0 || + input.correctMessages > MAX_MESSAGES + ) { + return invalid; + } + if (!Number.isInteger(input.incorrectMessages) || input.incorrectMessages < 0) { + return invalid; + } + if (!Number.isFinite(input.accuracy) || input.accuracy < 0 || input.accuracy > 1) { + return invalid; + } + const sprintTime = input.timeMs; + if ( + input.mode === "sprint" && + (typeof sprintTime !== "number" || + !Number.isFinite(sprintTime) || + sprintTime <= 0 || + sprintTime > MAX_SPRINT_TIME_MS) + ) { + return invalid; + } + return { + score, + mode: input.mode, + timeMs: input.mode === "sprint" ? (sprintTime as number) : null, + }; +} \ No newline at end of file