Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker";
|
|
import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
interface BettingControlsProps {
|
|
readonly tableId: string;
|
|
readonly snapshotVersion: number;
|
|
readonly legalActions: PokerLegalActions;
|
|
readonly potTotal: number;
|
|
readonly yourTurn: boolean;
|
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
|
}
|
|
|
|
function clampAmount(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) return min;
|
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
}
|
|
|
|
function amountLabel(value: number): string {
|
|
return value.toLocaleString();
|
|
}
|
|
|
|
export function BettingControls({
|
|
tableId,
|
|
snapshotVersion,
|
|
legalActions,
|
|
potTotal,
|
|
yourTurn,
|
|
onSnapshot,
|
|
}: BettingControlsProps) {
|
|
const [pendingAction, setPendingAction] = useState<PokerActionKind | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [amount, setAmount] = useState(legalActions.minBetTo);
|
|
|
|
const wagerKind: PokerActionKind | null = legalActions.canBet
|
|
? "bet"
|
|
: legalActions.canRaise
|
|
? "raise"
|
|
: null;
|
|
const wagerMin = legalActions.canBet ? legalActions.minBetTo : legalActions.minRaiseTo;
|
|
const wagerMax = legalActions.canBet ? legalActions.maxBetTo : legalActions.maxRaiseTo;
|
|
const canAct = yourTurn && pendingAction === null;
|
|
|
|
useEffect(() => {
|
|
setAmount((current) => clampAmount(current, wagerMin, wagerMax));
|
|
}, [wagerMax, wagerMin]);
|
|
|
|
useEffect(() => {
|
|
setPendingAction(null);
|
|
setError(null);
|
|
}, [snapshotVersion]);
|
|
|
|
const submit = async (kind: PokerActionKind, wager?: number) => {
|
|
setError(null);
|
|
setPendingAction(kind);
|
|
try {
|
|
const result = await pokerAction(tableId, kind, wager);
|
|
if (!result.success) {
|
|
setError(result.error ?? "That action was rejected by the table.");
|
|
setPendingAction(null);
|
|
return;
|
|
}
|
|
if (result.data) onSnapshot(result.data);
|
|
} catch (caught) {
|
|
setError(caught instanceof Error ? caught.message : "Could not send the table action.");
|
|
setPendingAction(null);
|
|
}
|
|
};
|
|
|
|
const chooseQuickAmount = (choice: "min" | "half" | "pot" | "all") => {
|
|
const nextAmount =
|
|
choice === "min"
|
|
? wagerMin
|
|
: choice === "half"
|
|
? Math.round(potTotal / 2)
|
|
: choice === "pot"
|
|
? potTotal
|
|
: wagerMax;
|
|
setAmount(clampAmount(nextAmount, wagerMin, wagerMax));
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={!canAct || !legalActions.canFold}
|
|
onClick={() => void submit("fold")}
|
|
>
|
|
Fold
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={!canAct || !legalActions.canCheck}
|
|
onClick={() => void submit("check")}
|
|
>
|
|
Check
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={!canAct || !legalActions.canCall}
|
|
onClick={() => void submit("call")}
|
|
>
|
|
Call {amountLabel(legalActions.callAmount)}
|
|
</Button>
|
|
</div>
|
|
|
|
{wagerKind && wagerMax >= wagerMin && (
|
|
<Card className="rounded-none">
|
|
<CardHeader className="p-4 pb-2">
|
|
<CardTitle className="text-sm">
|
|
{wagerKind === "bet" ? "Bet size" : "Raise to"}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-3 p-4 pt-0">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range"
|
|
min={wagerMin}
|
|
max={wagerMax}
|
|
step={1}
|
|
value={amount}
|
|
onChange={(event) =>
|
|
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
|
}
|
|
disabled={!canAct}
|
|
className="h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
|
|
aria-label={`${wagerKind === "bet" ? "Bet" : "Raise"} amount`}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
min={wagerMin}
|
|
max={wagerMax}
|
|
step={1}
|
|
value={amount}
|
|
onChange={(event) =>
|
|
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
|
}
|
|
disabled={!canAct}
|
|
className="w-24 font-mono tabular-nums"
|
|
aria-label="Wager amount"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(["min", "half", "pot", "all"] as const).map((choice) => (
|
|
<Button
|
|
key={choice}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!canAct}
|
|
onClick={() => chooseQuickAmount(choice)}
|
|
>
|
|
{choice === "min"
|
|
? "Min"
|
|
: choice === "half"
|
|
? "1/2 pot"
|
|
: choice === "pot"
|
|
? "Pot"
|
|
: "All-in"}
|
|
</Button>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
className="ml-auto"
|
|
disabled={!canAct}
|
|
onClick={() => void submit(wagerKind, amount)}
|
|
>
|
|
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="flex min-h-5 items-center justify-between gap-3 text-xs" aria-live="polite">
|
|
{!yourTurn && <span className="text-muted-foreground">Waiting for the table to act.</span>}
|
|
{pendingAction && (
|
|
<span className="text-muted-foreground">Waiting for the table update...</span>
|
|
)}
|
|
{error && <span className="text-destructive">{error}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|