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,
|
fetchPokerTable,
|
||||||
joinPokerTable,
|
joinPokerTable,
|
||||||
leavePokerTable,
|
leavePokerTable,
|
||||||
|
spectatePokerTable,
|
||||||
startPokerGame,
|
startPokerGame,
|
||||||
type PokerSnapshot,
|
type PokerSnapshot,
|
||||||
} from "@/app/(frontend)/minigames/poker/actions";
|
} from "@/app/(frontend)/minigames/poker/actions";
|
||||||
|
|
@ -12,6 +13,7 @@ import type { PokerPhase } from "@/lib/minigames/poker";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ChipBankCard } from "@/components/frontend/minigames/ChipBankCard";
|
||||||
import { PokerGuide } from "./PokerGuide";
|
import { PokerGuide } from "./PokerGuide";
|
||||||
import {
|
import {
|
||||||
POKER_STATE_EVENT,
|
POKER_STATE_EVENT,
|
||||||
|
|
@ -30,6 +32,9 @@ interface PokerArenaProps {
|
||||||
readonly currentUserId: number | string;
|
readonly currentUserId: number | string;
|
||||||
readonly initialTableId?: string;
|
readonly initialTableId?: string;
|
||||||
readonly isSuperuser: boolean;
|
readonly isSuperuser: boolean;
|
||||||
|
readonly superuserIds: readonly number[];
|
||||||
|
readonly initialChipBank: number;
|
||||||
|
readonly initialChipTopUpAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArenaView = "lobby" | "table";
|
type ArenaView = "lobby" | "table";
|
||||||
|
|
@ -47,6 +52,9 @@ export function PokerArena({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
initialTableId,
|
initialTableId,
|
||||||
isSuperuser,
|
isSuperuser,
|
||||||
|
superuserIds,
|
||||||
|
initialChipBank,
|
||||||
|
initialChipTopUpAt,
|
||||||
}: PokerArenaProps) {
|
}: PokerArenaProps) {
|
||||||
const [view, setView] = useState<ArenaView>(initialTableId ? "table" : "lobby");
|
const [view, setView] = useState<ArenaView>(initialTableId ? "table" : "lobby");
|
||||||
const [snapshot, setSnapshot] = useState<PokerSnapshot | null>(null);
|
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;
|
const seated = snapshot?.seats.some((id) => String(id) === String(currentUserId)) ?? false;
|
||||||
if (activeTableId && seated) {
|
if (activeTableId && seated) {
|
||||||
try {
|
try {
|
||||||
|
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
||||||
const result = await leavePokerTable(activeTableId);
|
const result = await leavePokerTable(activeTableId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setTableError(result.error ?? "Could not leave the poker table.");
|
setTableError(result.error ?? "Could not leave the poker table.");
|
||||||
|
|
@ -196,6 +205,7 @@ export function PokerArena({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setTableError(null);
|
setTableError(null);
|
||||||
try {
|
try {
|
||||||
|
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
||||||
const result = await joinPokerTable(activeTableId);
|
const result = await joinPokerTable(activeTableId);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
applySnapshot(result.data);
|
applySnapshot(result.data);
|
||||||
|
|
@ -209,9 +219,28 @@ export function PokerArena({
|
||||||
}
|
}
|
||||||
}, [activeTableId, applySnapshot]);
|
}, [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 () => {
|
const startCurrentTable = useCallback(async () => {
|
||||||
if (!activeTableId) return;
|
if (!activeTableId) return;
|
||||||
try {
|
try {
|
||||||
|
window.dispatchEvent(new Event("chipBank:showSpinner"));
|
||||||
const result = await startPokerGame(activeTableId);
|
const result = await startPokerGame(activeTableId);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
applySnapshot(result.data);
|
applySnapshot(result.data);
|
||||||
|
|
@ -295,9 +324,12 @@ export function PokerArena({
|
||||||
tableError={tableError}
|
tableError={tableError}
|
||||||
onSnapshot={applySnapshot}
|
onSnapshot={applySnapshot}
|
||||||
onJoin={joinCurrentTable}
|
onJoin={joinCurrentTable}
|
||||||
|
onSpectate={spectateCurrentTable}
|
||||||
onStart={startCurrentTable}
|
onStart={startCurrentTable}
|
||||||
onLeave={leaveTable}
|
onLeave={leaveTable}
|
||||||
onBack={resetToLobby}
|
onBack={resetToLobby}
|
||||||
|
isSuperuser={isSuperuser}
|
||||||
|
superuserIds={superuserIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -307,6 +339,8 @@ export function PokerArena({
|
||||||
activePlayers={activePlayers}
|
activePlayers={activePlayers}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChipBankCard initialChipBank={initialChipBank} initialChipTopUpAt={initialChipTopUpAt} />
|
||||||
</div>
|
</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