1
0
Fork 0

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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-08 15:57:01 -04:00
parent eeab85ad35
commit 987e78babd
2 changed files with 178 additions and 0 deletions

View file

@ -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;
}

View file

@ -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<CreditEodXpResult | null> {
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<string, unknown>;
};
const { xp: _xp, ...progressionRest } = progression;
const minigames = progressionRest.minigames ?? {};
const eod = (minigames.eodMinesweeper ?? {}) as Record<string, unknown> & {
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 };
}