1
0
Fork 0
polaris-task-force/src/lib/minigames/custom/eod.ts
2026-09-09 00:02:59 -04:00

253 lines
No EOL
8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}