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
|
|
@ -134,3 +134,120 @@ export function minefieldFlagsError(flags: number, mineCount: number): string |
|
||||||
}
|
}
|
||||||
return undefined;
|
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