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