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>
This commit is contained in:
parent
474aff734d
commit
67971dd7e6
8 changed files with 27406 additions and 0 deletions
71
src/app/(frontend)/minigames/chips.ts
Normal file
71
src/app/(frontend)/minigames/chips.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use server";
|
||||
|
||||
/**
|
||||
* Server-action surface for the persistent chip pool shared by the poker
|
||||
* and blackjack minigames. Balance state reads and the daily top-up claim
|
||||
* live here; the games themselves never create chips.
|
||||
*/
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import {
|
||||
claimChipTopUpForUser,
|
||||
readChipBankState,
|
||||
type ChipBankState,
|
||||
} from "@/lib/minigames/chips";
|
||||
|
||||
export type { ChipBankState } from "@/lib/minigames/chips";
|
||||
|
||||
interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({
|
||||
headers: hdrs,
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
function toError(error: unknown): { success: false; error: string } {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (message === "Unauthorized") {
|
||||
return { success: false, error: "You must be logged in." };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
export async function fetchChipBank(): Promise<ActionResult<ChipBankState>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const state = await readChipBankState(payload, Number(user.id));
|
||||
if (!state) {
|
||||
return { success: false, error: "No profile found for this user." };
|
||||
}
|
||||
return { success: true, data: state };
|
||||
} catch (error) {
|
||||
return toError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimChipTopUp(): Promise<ActionResult<ChipBankState>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const state = await claimChipTopUpForUser(payload, Number(user.id));
|
||||
return { success: true, data: state };
|
||||
} catch (error) {
|
||||
return toError(error);
|
||||
}
|
||||
}
|
||||
143
src/components/frontend/minigames/ChipBankCard.tsx
Normal file
143
src/components/frontend/minigames/ChipBankCard.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
claimChipTopUp,
|
||||
fetchChipBank,
|
||||
type ChipBankState,
|
||||
} from "@/app/(frontend)/minigames/chips";
|
||||
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||
import { playChipBankDown, playChipBankUp } from "./chipBankSounds";
|
||||
|
||||
interface ChipBankCardProps {
|
||||
readonly initialChipBank: number;
|
||||
readonly initialChipTopUpAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed upper-right balance card shared by the card minigames. Polls the
|
||||
* bank (dev SSE is unreliable), plays a rising/falling sound and flashes the
|
||||
* card on movement, and pops the delta large-to-small-to-faded. The fixed
|
||||
* position keeps it visible while scrolling the page.
|
||||
*/
|
||||
export function ChipBankCard({ initialChipBank, initialChipTopUpAt }: ChipBankCardProps) {
|
||||
const [chipState, setChipState] = useState<ChipBankState>({
|
||||
chipBank: initialChipBank,
|
||||
topUpAvailable: false,
|
||||
nextTopUpAt: initialChipTopUpAt,
|
||||
});
|
||||
const previousChipBankRef = useRef<number | null>(null);
|
||||
const [chipDelta, setChipDelta] = useState<{ amount: number; key: number } | null>(null);
|
||||
const [showingSpinner, setShowingSpinner] = useState(false);
|
||||
|
||||
// Show the spinner while an arena-triggered refresh is expected (join/leave).
|
||||
// Auto-hides after 15s so a failed action can never leave it stuck.
|
||||
const spinnerTimerRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setShowingSpinner(true);
|
||||
if (spinnerTimerRef.current !== null) window.clearTimeout(spinnerTimerRef.current);
|
||||
spinnerTimerRef.current = window.setTimeout(() => {
|
||||
setShowingSpinner(false);
|
||||
spinnerTimerRef.current = null;
|
||||
}, 15000);
|
||||
};
|
||||
window.addEventListener("chipBank:showSpinner", handler);
|
||||
return () => {
|
||||
window.removeEventListener("chipBank:showSpinner", handler);
|
||||
if (spinnerTimerRef.current !== null) window.clearTimeout(spinnerTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshChipState = useCallback(() => {
|
||||
void fetchChipBank()
|
||||
.then((result) => {
|
||||
if (result.success && result.data) setChipState(result.data);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshChipState();
|
||||
}, [refreshChipState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ chipBank?: number }>).detail;
|
||||
if (!detail || detail.chipBank === undefined) return;
|
||||
setChipState((current) => ({ ...current, chipBank: detail.chipBank as number }));
|
||||
refreshChipState();
|
||||
};
|
||||
window.addEventListener(PROFILE_UPDATE_EVENT, handler);
|
||||
return () => window.removeEventListener(PROFILE_UPDATE_EVENT, handler);
|
||||
}, [refreshChipState]);
|
||||
|
||||
useEffect(() => {
|
||||
const poll = window.setInterval(refreshChipState, 5000);
|
||||
return () => window.clearInterval(poll);
|
||||
}, [refreshChipState]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousChipBankRef.current;
|
||||
previousChipBankRef.current = chipState.chipBank;
|
||||
if (previous === null || previous === chipState.chipBank) return;
|
||||
setShowingSpinner(false);
|
||||
const amount = chipState.chipBank - previous;
|
||||
setChipDelta({ amount, key: Date.now() });
|
||||
if (amount > 0) playChipBankUp();
|
||||
else playChipBankDown();
|
||||
const timer = setTimeout(() => setChipDelta(null), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [chipState.chipBank]);
|
||||
|
||||
const claimTopUp = useCallback(async () => {
|
||||
const result = await claimChipTopUp();
|
||||
if (result.success && result.data) setChipState(result.data);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"chip-bank-card fixed right-4 top-4 z-40 flex w-48 flex-col gap-1 rounded-md border p-3",
|
||||
chipDelta && (chipDelta.amount > 0 ? "chip-bank-flash-up" : "chip-bank-flash-down"),
|
||||
)}
|
||||
aria-label="Your chip bank"
|
||||
>
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">Chip bank</span>
|
||||
<span className="font-mono text-lg font-semibold tabular-nums">
|
||||
{chipState.chipBank.toLocaleString()}
|
||||
{chipDelta && (
|
||||
<span
|
||||
key={chipDelta.key}
|
||||
className={cn(
|
||||
"chip-bank-delta ml-2 inline-block text-xs font-normal",
|
||||
chipDelta.amount > 0 ? "text-emerald-400" : "text-rose-400",
|
||||
)}
|
||||
>
|
||||
{chipDelta.amount > 0 ? "+" : ""}
|
||||
{chipDelta.amount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showingSpinner && (
|
||||
<span
|
||||
className="chip-bank-spinner absolute right-4 top-1/2 -translate-y-1/2"
|
||||
aria-label="Updating balance"
|
||||
/>
|
||||
)}
|
||||
{chipState.topUpAvailable && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 self-start px-2 text-xs"
|
||||
onClick={claimTopUp}
|
||||
>
|
||||
Claim chips
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/frontend/minigames/chipBankSounds.ts
Normal file
69
src/components/frontend/minigames/chipBankSounds.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Chip-bank movement sounds for the fixed Chip Bank card. Independent of the
|
||||
* table sound engines (the card lives on both the poker and blackjack pages,
|
||||
* outside any table). Best-effort autoplay: if the browser blocks the
|
||||
* AudioContext until a gesture, these simply stay silent.
|
||||
*/
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
if (!ctx) {
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
ctx = new Ctor();
|
||||
}
|
||||
if (ctx.state === "suspended") {
|
||||
void ctx.resume().catch(() => undefined);
|
||||
}
|
||||
return ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tone(
|
||||
frequency: number,
|
||||
nextFrequency: number,
|
||||
duration: number,
|
||||
delay: number,
|
||||
volume = 0.14,
|
||||
): void {
|
||||
const context = getCtx();
|
||||
if (!context) return;
|
||||
try {
|
||||
const start = context.currentTime + delay;
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(frequency, start);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, nextFrequency), start + duration);
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, start + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + duration + 0.05);
|
||||
} catch {
|
||||
// Sound is best-effort; never throw from a balance indicator.
|
||||
}
|
||||
}
|
||||
|
||||
/** Chips arriving: short rising pair. */
|
||||
export function playChipBankUp(): void {
|
||||
tone(523, 523, 0.09, 0);
|
||||
tone(784, 784, 0.12, 0.09);
|
||||
}
|
||||
|
||||
/** Chips leaving: short falling pair. */
|
||||
export function playChipBankDown(): void {
|
||||
tone(392, 392, 0.09, 0);
|
||||
tone(262, 262, 0.14, 0.09);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
fetchPokerTable,
|
||||
joinPokerTable,
|
||||
leavePokerTable,
|
||||
spectatePokerTable,
|
||||
startPokerGame,
|
||||
type PokerSnapshot,
|
||||
} from "@/app/(frontend)/minigames/poker/actions";
|
||||
|
|
@ -12,6 +13,7 @@ import type { PokerPhase } from "@/lib/minigames/poker";
|
|||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChipBankCard } from "@/components/frontend/minigames/ChipBankCard";
|
||||
import { PokerGuide } from "./PokerGuide";
|
||||
import {
|
||||
POKER_STATE_EVENT,
|
||||
|
|
@ -30,6 +32,9 @@ interface PokerArenaProps {
|
|||
readonly currentUserId: number | string;
|
||||
readonly initialTableId?: string;
|
||||
readonly isSuperuser: boolean;
|
||||
readonly superuserIds: readonly number[];
|
||||
readonly initialChipBank: number;
|
||||
readonly initialChipTopUpAt: string | null;
|
||||
}
|
||||
|
||||
type ArenaView = "lobby" | "table";
|
||||
|
|
@ -47,6 +52,9 @@ export function PokerArena({
|
|||
currentUserId,
|
||||
initialTableId,
|
||||
isSuperuser,
|
||||
superuserIds,
|
||||
initialChipBank,
|
||||
initialChipTopUpAt,
|
||||
}: PokerArenaProps) {
|
||||
const [view, setView] = useState<ArenaView>(initialTableId ? "table" : "lobby");
|
||||
const [snapshot, setSnapshot] = useState<PokerSnapshot | null>(null);
|
||||
|
|
@ -176,6 +184,7 @@ export function PokerArena({
|
|||
const seated = snapshot?.seats.some((id) => String(id) === String(currentUserId)) ?? false;
|
||||
if (activeTableId && seated) {
|
||||
try {
|
||||
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
||||
const result = await leavePokerTable(activeTableId);
|
||||
if (!result.success) {
|
||||
setTableError(result.error ?? "Could not leave the poker table.");
|
||||
|
|
@ -196,6 +205,7 @@ export function PokerArena({
|
|||
setLoading(true);
|
||||
setTableError(null);
|
||||
try {
|
||||
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
||||
const result = await joinPokerTable(activeTableId);
|
||||
if (result.success && result.data) {
|
||||
applySnapshot(result.data);
|
||||
|
|
@ -209,9 +219,28 @@ export function PokerArena({
|
|||
}
|
||||
}, [activeTableId, applySnapshot]);
|
||||
|
||||
const spectateCurrentTable = useCallback(async () => {
|
||||
if (!activeTableId) return;
|
||||
setLoading(true);
|
||||
setTableError(null);
|
||||
try {
|
||||
const result = await spectatePokerTable(activeTableId);
|
||||
if (result.success && result.data) {
|
||||
applySnapshot(result.data);
|
||||
} else {
|
||||
setTableError(result.error ?? "Could not spectate this table.");
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
setTableError(caught instanceof Error ? caught.message : "Could not spectate this table.");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTableId, applySnapshot]);
|
||||
|
||||
const startCurrentTable = useCallback(async () => {
|
||||
if (!activeTableId) return;
|
||||
try {
|
||||
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
||||
const result = await startPokerGame(activeTableId);
|
||||
if (result.success && result.data) {
|
||||
applySnapshot(result.data);
|
||||
|
|
@ -295,9 +324,12 @@ export function PokerArena({
|
|||
tableError={tableError}
|
||||
onSnapshot={applySnapshot}
|
||||
onJoin={joinCurrentTable}
|
||||
onSpectate={spectateCurrentTable}
|
||||
onStart={startCurrentTable}
|
||||
onLeave={leaveTable}
|
||||
onBack={resetToLobby}
|
||||
isSuperuser={isSuperuser}
|
||||
superuserIds={superuserIds}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -307,6 +339,8 @@ export function PokerArena({
|
|||
activePlayers={activePlayers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChipBankCard initialChipBank={initialChipBank} initialChipTopUpAt={initialChipTopUpAt} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
202
src/lib/minigames/chips.ts
Normal file
202
src/lib/minigames/chips.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
26805
src/migrations/20260914_153046_add_chip_bank.json
Normal file
26805
src/migrations/20260914_153046_add_chip_bank.json
Normal file
File diff suppressed because it is too large
Load diff
13
src/migrations/20260914_153046_add_chip_bank.ts
Normal file
13
src/migrations/20260914_153046_add_chip_bank.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_chip_bank" numeric DEFAULT 0;
|
||||
ALTER TABLE "profiles" ADD COLUMN "progression_chip_bank_refilled_at" timestamp(3) with time zone;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_chip_bank";
|
||||
ALTER TABLE "profiles" DROP COLUMN "progression_chip_bank_refilled_at";`)
|
||||
}
|
||||
69
tests/int/chips.int.spec.ts
Normal file
69
tests/int/chips.int.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CHIP_PER_XP,
|
||||
CHIP_TOP_UP_COOLDOWN_MS,
|
||||
CHIP_TOP_UP_THRESHOLD,
|
||||
chipTopUpState,
|
||||
chipsForXp,
|
||||
} from "@/lib/minigames/chips";
|
||||
|
||||
describe("chipsForXp", () => {
|
||||
it("converts XP to chips at the fixed rate", () => {
|
||||
expect(CHIP_PER_XP).toBe(10);
|
||||
expect(chipsForXp(1)).toBe(10);
|
||||
expect(chipsForXp(5)).toBe(50);
|
||||
expect(chipsForXp(50)).toBe(500);
|
||||
});
|
||||
|
||||
it("rounds fractional XP to whole chips", () => {
|
||||
expect(chipsForXp(0.5)).toBe(5);
|
||||
expect(chipsForXp(1.25)).toBe(13);
|
||||
});
|
||||
|
||||
it("never returns negative chips", () => {
|
||||
expect(chipsForXp(0)).toBe(0);
|
||||
expect(chipsForXp(-3)).toBe(0);
|
||||
expect(chipsForXp(Number.NaN)).toBe(0);
|
||||
expect(chipsForXp(Number.POSITIVE_INFINITY)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chipTopUpState", () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
it("is claimable for a first-time broke player (no refill timestamp)", () => {
|
||||
const state = chipTopUpState(0, null, NOW);
|
||||
expect(state.topUpAvailable).toBe(true);
|
||||
expect(state.nextTopUpAt).toBeNull();
|
||||
});
|
||||
|
||||
it("is not claimable while the pool holds the threshold or more", () => {
|
||||
expect(chipTopUpState(CHIP_TOP_UP_THRESHOLD, null, NOW).topUpAvailable).toBe(false);
|
||||
expect(chipTopUpState(1500, null, NOW).topUpAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it("is claimable below the threshold even with a stale refill", () => {
|
||||
const stale = new Date(NOW - CHIP_TOP_UP_COOLDOWN_MS - 1).toISOString();
|
||||
const state = chipTopUpState(499, stale, NOW);
|
||||
expect(state.topUpAvailable).toBe(true);
|
||||
expect(state.nextTopUpAt).toBeNull();
|
||||
});
|
||||
|
||||
it("is locked during the cooldown window and reports when it unlocks", () => {
|
||||
const recent = new Date(NOW - 60_000).toISOString();
|
||||
const state = chipTopUpState(0, recent, NOW);
|
||||
expect(state.topUpAvailable).toBe(false);
|
||||
expect(state.nextTopUpAt).toBe(new Date(NOW - 60_000 + CHIP_TOP_UP_COOLDOWN_MS).toISOString());
|
||||
});
|
||||
|
||||
it("unlocks the moment the cooldown expires", () => {
|
||||
const boundary = new Date(NOW - CHIP_TOP_UP_COOLDOWN_MS).toISOString();
|
||||
expect(chipTopUpState(0, boundary, NOW).topUpAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an unparseable refill timestamp as never refilled", () => {
|
||||
const state = chipTopUpState(0, "not-a-date", NOW);
|
||||
expect(state.topUpAvailable).toBe(true);
|
||||
expect(state.nextTopUpAt).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue