1
0
Fork 0
polaris-task-force/src/lib/minigames/chips.ts
Z8MB1E 67971dd7e6 feat(chips): persistent chip pool economy with shared balance card
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>
2026-09-14 15:38:05 -04:00

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