Chip service and actions, fixed ChipBankCard with delta popup, sounds, spinner, and arena integration; chip bank migration and tests. Arena diffs are the chip-card mounts and join/leave spinner dispatches. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
/**
|
|
* 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<ChipBankState | null> {
|
|
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<ChipBuyInResult> {
|
|
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<void> {
|
|
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<ChipBankState> {
|
|
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<ChipBuyInResult> {
|
|
const progression = (profile.progression ?? {}) as ProfileProgression;
|
|
const { xp: _xp, ...progressionRest } = progression;
|
|
const data: Record<string, unknown> = {
|
|
...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 };
|
|
}
|