1
0
Fork 0

feat(minigames): extract shared custom-minigame validation and unlock libs

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-08 14:38:52 -04:00
parent 1f99e34c0d
commit 5b4842943e
2 changed files with 267 additions and 0 deletions

View file

@ -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<number>();
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;
}

View file

@ -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,
};
}