/** * Persistent chip pool shared by the poker and blackjack tables. * * The pool lives on Profiles at progression.chipBank (plus * progression.chipBankRefilledAt for the daily top-up cooldown). Chips enter * the pool only by playing the non-card minigames (1 XP = CHIP_PER_XP chips); * the card games are pure sinks: buy-ins deduct from the pool and settled * bankrolls credit back, so the pool never creates or destroys chips itself. * * Server-only module (imports the realtime bus). The pure helpers at the top * are safe to unit-test without a database. */ import type { Payload } from "payload"; import { broadcastToUser } from "@/lib/realtime/bus"; /** Chips earned per XP point in the non-card minigames. */ export const CHIP_PER_XP = 10; /** Chips a daily top-up adds when the pool runs dry. */ export const CHIP_TOP_UP_AMOUNT = 500; /** The top-up is claimable while the pool is below this many chips. */ export const CHIP_TOP_UP_THRESHOLD = 500; /** One top-up claim per day. */ export const CHIP_TOP_UP_COOLDOWN_MS = 24 * 60 * 60_000; /** Chips earned for a run that granted xpGained XP (never negative). */ export function chipsForXp(xpGained: number): number { if (!Number.isFinite(xpGained) || xpGained <= 0) return 0; return Math.round(xpGained * CHIP_PER_XP); } export interface ChipBankState { chipBank: number; topUpAvailable: boolean; /** ISO timestamp of when the next top-up unlocks; null when claimable now. */ nextTopUpAt: string | null; } /** Pure eligibility + countdown math for the daily top-up. */ export function chipTopUpState( chipBank: number, refilledAt: string | null | undefined, now: number = Date.now(), ): ChipBankState { if (refilledAt) { const refilled = Date.parse(refilledAt); if (Number.isFinite(refilled)) { const elapsed = now - refilled; if (elapsed < CHIP_TOP_UP_COOLDOWN_MS) { return { chipBank, topUpAvailable: false, nextTopUpAt: new Date(refilled + CHIP_TOP_UP_COOLDOWN_MS).toISOString(), }; } } } return { chipBank, topUpAvailable: chipBank < CHIP_TOP_UP_THRESHOLD, nextTopUpAt: null }; } interface ProfileProgression { xp?: unknown; chipBank?: number | null; chipBankRefilledAt?: string | Date | null; } /** Payload may hand date fields back as Date objects; normalize to ISO strings. */ function isoOrNull(value: string | Date | null | undefined): string | null { if (!value) return null; if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString(); return value; } /** * Resolve a user's chip bank state; null when the user has no profile * (untracked users such as integration-test placeholders play without * pool accounting). */ export async function readChipBankState( payload: Payload, userId: number, ): Promise { const profile = await findProfile(payload, userId); if (!profile) return null; const progression = (profile.progression ?? {}) as ProfileProgression; return chipTopUpState(progression.chipBank ?? 0, isoOrNull(progression.chipBankRefilledAt)); } export interface ChipBuyInResult { /** false when the user has no profile (no pool accounting applies). */ settled: boolean; chipBank: number | null; } /** * Deduct a table buy-in from the user's pool. Throws (with both chip totals * in the message) when the pool cannot cover it; call sites wrap the error * with the player's display name. */ export async function spendChipBuyIn( payload: Payload, userId: number, amount: number, ): Promise { const profile = await findProfile(payload, userId); if (!profile) return { settled: false, chipBank: null }; const progression = (profile.progression ?? {}) as ProfileProgression; const current = progression.chipBank ?? 0; if (current < amount) { throw new Error( `Not enough chips: ${current} of ${amount} needed. Play the other minigames to earn more chips.`, ); } return writeChipBank(payload, profile, userId, current - amount); } /** * Credit settled chips back to the pool (fire-and-forget style: callers may * void this; failures are logged and skipped so one broken profile cannot * block a table teardown). */ export async function creditChips(payload: Payload, userId: number, amount: number): Promise { if (!Number.isFinite(amount) || amount <= 0) return; const profile = await findProfile(payload, userId); if (!profile) return; const progression = (profile.progression ?? {}) as ProfileProgression; const current = progression.chipBank ?? 0; await writeChipBank(payload, profile, userId, current + amount); } /** * Claim the daily top-up: tops the pool up to CHIP_TOP_UP_AMOUNT while the * balance is under the threshold, at most once per cooldown window. Throws * with a user-facing message when not eligible. */ export async function claimChipTopUpForUser( payload: Payload, userId: number, ): Promise { const profile = await findProfile(payload, userId); if (!profile) throw new Error("No profile found for this user."); const progression = (profile.progression ?? {}) as ProfileProgression; const state = chipTopUpState( progression.chipBank ?? 0, isoOrNull(progression.chipBankRefilledAt), ); if (!state.topUpAvailable) { throw new Error("Your chip top-up is not available yet. Come back later."); } const next = Math.max(state.chipBank, CHIP_TOP_UP_AMOUNT); await writeChipBank(payload, profile, userId, next, new Date().toISOString()); return chipTopUpState(next, new Date().toISOString()); } async function findProfile(payload: Payload, userId: number) { const profileRes = await payload.find({ collection: "profiles", where: { user: { equals: userId } }, limit: 1, depth: 0, overrideAccess: true, }); return profileRes.docs[0] ?? null; } /** * Persist a new chip bank value (stripping the virtual xp key, mirroring the * minigame action convention), then push the new absolute balance to the * user's SSE channel so open card tables and dashboards stay in sync. */ async function writeChipBank( payload: Payload, profile: { id: number | string; progression?: unknown }, userId: number, chipBank: number, refilledAt?: string, ): Promise { const progression = (profile.progression ?? {}) as ProfileProgression; const { xp: _xp, ...progressionRest } = progression; const data: Record = { ...progressionRest, chipBank: Math.max(0, chipBank), }; if (refilledAt) data.chipBankRefilledAt = refilledAt; await payload.update({ collection: "profiles", id: profile.id, data: { progression: data }, overrideAccess: true, depth: 0, }); broadcastToUser("profile-update", userId, { reason: "chips", chipBank: data.chipBank, profileId: profile.id, }); return { settled: true, chipBank: data.chipBank as number }; }