Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
253 lines
No EOL
8 KiB
TypeScript
253 lines
No EOL
8 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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;
|
||
} |