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:
parent
eeab85ad35
commit
987e78babd
2 changed files with 178 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
61
src/lib/custom-minigames/payouts.ts
Normal file
61
src/lib/custom-minigames/payouts.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue