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:
parent
1f99e34c0d
commit
5b4842943e
2 changed files with 267 additions and 0 deletions
136
src/lib/custom-minigames/eod.ts
Normal file
136
src/lib/custom-minigames/eod.ts
Normal 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;
|
||||||
|
}
|
||||||
131
src/lib/custom-minigames/radio.ts
Normal file
131
src/lib/custom-minigames/radio.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue