1
0
Fork 0

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:
Jason Fraley 2026-09-14 15:38:05 -04:00
parent 474aff734d
commit 67971dd7e6
8 changed files with 27406 additions and 0 deletions

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

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

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

View file

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

File diff suppressed because it is too large Load diff

View 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";`)
}

View 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();
});
});