From 987e78babd269fdb58c8a8354e881f3741e38f72 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 15:57:01 -0400 Subject: [PATCH] feat(minigames): add custom payout math and loss-reveal helpers to shared libs Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/lib/custom-minigames/eod.ts | 117 ++++++++++++++++++++++++++++ src/lib/custom-minigames/payouts.ts | 61 +++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/lib/custom-minigames/payouts.ts diff --git a/src/lib/custom-minigames/eod.ts b/src/lib/custom-minigames/eod.ts index f4734bc..7e515c9 100644 --- a/src/lib/custom-minigames/eod.ts +++ b/src/lib/custom-minigames/eod.ts @@ -133,4 +133,121 @@ export function minefieldFlagsError(flags: number, mineCount: number): string | 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; } \ No newline at end of file diff --git a/src/lib/custom-minigames/payouts.ts b/src/lib/custom-minigames/payouts.ts new file mode 100644 index 0000000..8ccff5e --- /dev/null +++ b/src/lib/custom-minigames/payouts.ts @@ -0,0 +1,61 @@ +import type { Payload } from "payload"; + +export interface CreditEodXpResult { + profileId: number; + xpEarned: number; +} + +/** + * Credits EOD minigame XP to a user's profile, mirroring the write shape of + * the standard EOD action (including stripping the engine-managed + * `progression.xp` key). Returns null when the user has no profile. + * + * Server-side only (needs a live Payload instance); the payout math lives in + * the pure `custom-minigames/eod` lib. + */ +export async function creditEodXp( + payload: Payload, + userId: number, + amount: number, +): Promise { + const profileRes = await payload.find({ + collection: "profiles", + where: { user: { equals: userId } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const profile = profileRes.docs[0]; + if (!profile) { + return null; + } + + const progression = (profile.progression ?? {}) as { + xp?: unknown; + minigames?: Record; + }; + const { xp: _xp, ...progressionRest } = progression; + const minigames = progressionRest.minigames ?? {}; + const eod = (minigames.eodMinesweeper ?? {}) as Record & { + xpEarned?: number | null; + }; + const xpEarned = (eod.xpEarned ?? 0) + amount; + + await payload.update({ + collection: "profiles", + id: profile.id, + data: { + progression: { + ...progressionRest, + minigames: { + ...minigames, + eodMinesweeper: { ...eod, xpEarned }, + }, + }, + }, + overrideAccess: true, + depth: 0, + }); + + return { profileId: profile.id, xpEarned }; +}