MISC: Validate bet input of casino mini games (#1694)

This commit is contained in:
catloversg
2025-06-24 07:43:04 +07:00
committed by GitHub
parent 9ba1bc7cfb
commit 86d5a023be
6 changed files with 246 additions and 208 deletions

86
src/Casino/BetInput.tsx Normal file
View File

@@ -0,0 +1,86 @@
import TextField from "@mui/material/TextField";
import React, { useState } from "react";
import { Settings } from "../Settings/Settings";
import { formatMoney } from "../ui/formatNumber";
export interface BetInputProps {
initialBet: number;
maxBet: number;
gameInProgress: boolean;
setBet: (bet: number) => void;
validBetCallback?: () => void;
invalidBetCallback?: () => void;
}
export function BetInput({
initialBet,
maxBet,
gameInProgress,
setBet,
validBetCallback,
invalidBetCallback,
}: BetInputProps): React.ReactElement {
const [betValue, setBetValue] = useState<string>(initialBet.toString());
const [helperText, setHelperText] = useState<string>("");
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const betInput = event.target.value;
setBetValue(betInput);
const bet = Math.round(parseFloat(betInput));
let isValid = false;
/**
* We intentionally do not check if the player has enough money. The player's money can change between these checks
* and when the bet is actually used.
*/
if (isNaN(bet)) {
setBet(0);
setHelperText("Not a valid number");
} else if (bet <= 0) {
setBet(0);
setHelperText("Must bet a positive amount");
} else if (bet > maxBet) {
// This is for the player's convenience. They can type a bunch of 9s, and we will set the max bet for them.
setBetValue(String(maxBet));
setBet(maxBet);
} else {
// Valid wager
isValid = true;
setBet(bet);
setHelperText("");
}
if (isValid) {
if (validBetCallback) {
validBetCallback();
}
} else {
if (invalidBetCallback) {
invalidBetCallback();
}
}
};
return (
<TextField
sx={{
marginTop: "20px",
marginBottom: "20px",
width: "200px",
"& .MuiInputLabel-root.Mui-disabled": {
WebkitTextFillColor: Settings.theme.disabled,
},
"& .MuiInputBase-input.Mui-disabled": {
WebkitTextFillColor: Settings.theme.disabled,
},
}}
value={betValue}
label={<>Wager (Max: {formatMoney(maxBet)})</>}
disabled={gameInProgress}
onChange={onChange}
error={helperText !== ""}
helperText={helperText}
type="number"
InputProps={{
// Without startAdornment, label and placeholder are only shown when TextField is focused
startAdornment: <></>,
}}
/>
);
}

View File

@@ -1,23 +1,24 @@
import * as React from "react";
import { Player } from "@player";
import { Money } from "../ui/React/Money";
import { win, reachedLimit } from "./Game";
import { Deck } from "./CardDeck/Deck";
import { Hand } from "./CardDeck/Hand";
import { InputAdornment } from "@mui/material";
import { ReactCard } from "./CardDeck/ReactCard";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import { Money } from "../ui/React/Money";
import { BetInput } from "./BetInput";
import { Deck } from "./CardDeck/Deck";
import { Hand } from "./CardDeck/Hand";
import { ReactCard } from "./CardDeck/ReactCard";
import { hasEnoughMoney, reachedLimit, win } from "./Game";
import { exceptionAlert } from "../utils/helpers/exceptionAlert";
const initialBet = 1e6;
const maxBet = 100e6;
const MAX_BET = 100e6;
export const DECK_COUNT = 5; // 5-deck multideck
enum Result {
Pending = "",
Pending = "Pending",
PlayerWon = "You won!",
PlayerWonByBlackjack = "You Won! Blackjack!",
DealerWon = "You lost!",
@@ -44,8 +45,6 @@ export class Blackjack extends React.Component<Record<string, never>, State> {
this.deck = new Deck(DECK_COUNT);
const initialBet = 1e6;
this.state = {
playerHand: new Hand([]),
dealerHand: new Hand([]),
@@ -59,14 +58,8 @@ export class Blackjack extends React.Component<Record<string, never>, State> {
};
}
canStartGame = (): boolean => {
const { bet } = this.state;
return Player.canAfford(bet);
};
startGame = (): void => {
if (!this.canStartGame() || reachedLimit()) {
if (reachedLimit() || !hasEnoughMoney(this.state.bet)) {
return;
}
@@ -203,18 +196,37 @@ export class Blackjack extends React.Component<Record<string, never>, State> {
};
finishGame = (result: Result): void => {
const gains =
result === Result.DealerWon
? 0 // We took away the bet at the start, don't need to take more
: result === Result.Tie
? this.state.bet // We took away the bet at the start, give it back
: result === Result.PlayerWon
? 2 * this.state.bet // Give back their bet plus their winnings
: result === Result.PlayerWonByBlackjack
? 2.5 * this.state.bet // Blackjack pays out 1.5x bet!
: (() => {
throw new Error(`Unexpected result: ${result}`);
})(); // This can't happen, right?
/**
* Explicitly declare the type of "gains". If we forget a case here, TypeScript will notify us: "Variable 'gains' is
* used before being assigned.".
*/
let gains: number;
switch (result) {
case Result.DealerWon:
// We took away the bet at the start, don't need to take more
gains = 0;
break;
case Result.Tie:
// We took away the bet at the start, give it back
gains = this.state.bet;
break;
case Result.PlayerWon:
// Give back their bet plus their winnings
gains = 2 * this.state.bet;
break;
case Result.PlayerWonByBlackjack:
// Blackjack pays out 1.5x bet!
gains = 2.5 * this.state.bet;
break;
case Result.Pending:
/**
* Don't throw an error. Callers of this function are event handlers (onClick) of buttons. If we throw an error,
* it won't be shown to the player.
*/
exceptionAlert(new Error(`Unexpected Blackjack result: ${result}.`));
gains = 0;
break;
}
win(gains);
this.setState({
gameInProgress: false,
@@ -223,49 +235,6 @@ export class Blackjack extends React.Component<Record<string, never>, State> {
});
};
wagerOnChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const betInput = event.target.value;
const wager = Math.round(parseFloat(betInput));
if (isNaN(wager)) {
this.setState({
bet: 0,
betInput,
wagerInvalid: true,
wagerInvalidHelperText: "Not a valid number",
});
} else if (wager <= 0) {
this.setState({
bet: 0,
betInput,
wagerInvalid: true,
wagerInvalidHelperText: "Must bet a positive amount",
});
} else if (wager > MAX_BET) {
this.setState({
bet: 0,
betInput,
wagerInvalid: true,
wagerInvalidHelperText: "Exceeds max bet",
});
} else if (!Player.canAfford(wager)) {
this.setState({
bet: 0,
betInput,
wagerInvalid: true,
wagerInvalidHelperText: "Not enough money",
});
} else {
// Valid wager
this.setState({
bet: wager,
betInput,
wagerInvalid: false,
wagerInvalidHelperText: "",
result: Result.Pending, // Reset previous game status to clear the win/lose text UI
});
}
};
// Start game button
startOnClick = (event: React.MouseEvent): void => {
// Protect against scripting...although maybe this would be fun to automate
@@ -279,8 +248,7 @@ export class Blackjack extends React.Component<Record<string, never>, State> {
};
render(): React.ReactNode {
const { betInput, playerHand, dealerHand, gameInProgress, result, wagerInvalid, wagerInvalidHelperText, gains } =
this.state;
const { playerHand, dealerHand, gameInProgress, result, wagerInvalid, gains } = this.state;
// Get the player totals to display.
const playerHandValues = this.getHandDisplayValues(playerHand);
@@ -288,31 +256,26 @@ export class Blackjack extends React.Component<Record<string, never>, State> {
return (
<>
{/* Wager input */}
<Box>
<TextField
value={betInput}
label={
<>
{"Wager (Max: "}
<Money money={MAX_BET} />
{")"}
</>
}
disabled={gameInProgress}
onChange={this.wagerOnChange}
error={wagerInvalid}
helperText={wagerInvalid ? wagerInvalidHelperText : ""}
type="number"
style={{
width: "200px",
<BetInput
initialBet={initialBet}
maxBet={maxBet}
gameInProgress={gameInProgress}
setBet={(bet) => {
this.setState({
bet,
});
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Typography>$</Typography>
</InputAdornment>
),
validBetCallback={() => {
this.setState({
wagerInvalid: false,
result: Result.Pending,
});
}}
invalidBetCallback={() => {
this.setState({
wagerInvalid: true,
});
}}
/>
@@ -324,7 +287,7 @@ export class Blackjack extends React.Component<Record<string, never>, State> {
{/* Buttons */}
{!gameInProgress ? (
<Button onClick={this.startOnClick} disabled={wagerInvalid || !this.canStartGame()}>
<Button onClick={this.startOnClick} disabled={wagerInvalid}>
Start
</Button>
) : (

View File

@@ -1,54 +1,53 @@
import React, { useState } from "react";
import { hasEnoughMoney, reachedLimit, win } from "./Game";
import { BadRNG } from "./RNG";
import { win, reachedLimit } from "./Game";
import { trusted } from "./utils";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { BetInput } from "./BetInput";
const minPlay = 0;
const maxPlay = 10e3;
const initialBet = 1000;
const maxBet = 10e3;
enum CoinFlipResult {
Head = "Head",
Tail = "Tail",
}
export function CoinFlip(): React.ReactElement {
const [investment, setInvestment] = useState(1000);
const [result, setResult] = useState(<span> </span>);
const [investment, setInvestment] = useState(initialBet);
const [result, setResult] = useState(<span></span>);
const [status, setStatus] = useState("");
const [playLock, setPlayLock] = useState(false);
function updateInvestment(e: React.ChangeEvent<HTMLInputElement>): void {
let investment: number = parseInt(e.currentTarget.value);
if (isNaN(investment)) {
investment = minPlay;
function play(guess: CoinFlipResult): void {
if (reachedLimit() || !hasEnoughMoney(investment)) {
return;
}
if (investment > maxPlay) {
investment = maxPlay;
}
if (investment < minPlay) {
investment = minPlay;
}
setInvestment(investment);
}
function play(guess: string): void {
if (reachedLimit()) return;
const v = BadRNG.random();
let letter: string;
let letter: CoinFlipResult;
if (v < 0.5) {
letter = "H";
letter = CoinFlipResult.Head;
} else {
letter = "T";
letter = CoinFlipResult.Tail;
}
const correct: boolean = guess === letter;
const correct = guess === letter;
setResult(
<Box display="flex">
<Typography sx={{ lineHeight: "1em", whiteSpace: "pre" }} color={correct ? "primary" : "error"}>
<div>
<Typography component="span">Result:</Typography>
<Typography
component="span"
sx={{ lineHeight: "1em", whiteSpace: "pre" }}
color={correct ? "primary" : "error"}
>
{letter}
</Typography>
</Box>,
,
</div>,
);
setStatus(correct ? " win!" : "lose!");
setPlayLock(true);
@@ -59,31 +58,30 @@ export function CoinFlip(): React.ReactElement {
} else {
win(-investment);
}
if (reachedLimit()) return;
}
return (
<>
<Typography>Result:</Typography> {result}
<Box display="flex" alignItems="center">
<TextField
type="number"
onChange={updateInvestment}
InputProps={{
endAdornment: (
<>
<Button onClick={trusted(() => play("H"))} disabled={playLock}>
Head!
</Button>
<Button onClick={trusted(() => play("T"))} disabled={playLock}>
Tail!
</Button>
</>
),
<Box>
<BetInput
initialBet={initialBet}
maxBet={maxBet}
gameInProgress={playLock}
setBet={(bet) => {
setInvestment(bet);
}}
/>
<Box>
<Button onClick={trusted(() => play(CoinFlipResult.Head))} disabled={playLock}>
Head!
</Button>
<Button onClick={trusted(() => play(CoinFlipResult.Tail))} disabled={playLock}>
Tail!
</Button>
</Box>
</Box>
<Typography variant="h3">{status}</Typography>
{result}
<Typography variant="h4">{status}</Typography>
</>
);
}

View File

@@ -17,3 +17,11 @@ export function reachedLimit(): boolean {
}
return reached;
}
export function hasEnoughMoney(bet: number): boolean {
const result = Player.canAfford(bet);
if (!result) {
dialogBoxCreate("You do not have enough money.");
}
return result;
}

View File

@@ -1,15 +1,15 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { Money } from "../ui/React/Money";
import { win, reachedLimit } from "./Game";
import { BetInput } from "./BetInput";
import { hasEnoughMoney, reachedLimit, win } from "./Game";
import { WHRNG } from "./RNG";
import { trusted } from "./utils";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
const minPlay = 0;
const maxPlay = 1e7;
const initialBet = 1000;
const maxBet = 1e7;
function isRed(n: number): boolean {
return [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36].includes(n);
@@ -108,7 +108,7 @@ function Single(s: number): Strategy {
export function Roulette(): React.ReactElement {
const [rng] = useState(new WHRNG(new Date().getTime()));
const [investment, setInvestment] = useState(1000);
const [investment, setInvestment] = useState(initialBet);
const [canPlay, setCanPlay] = useState(true);
const [status, setStatus] = useState<string | JSX.Element>("waiting");
const [n, setN] = useState(0);
@@ -125,20 +125,6 @@ export function Roulette(): React.ReactElement {
}
}
function updateInvestment(e: React.ChangeEvent<HTMLInputElement>): void {
let investment: number = parseInt(e.currentTarget.value);
if (isNaN(investment)) {
investment = minPlay;
}
if (investment > maxPlay) {
investment = maxPlay;
}
if (investment < minPlay) {
investment = minPlay;
}
setInvestment(investment);
}
function currentNumber(): string {
if (n === 0) return "0";
const color = isRed(n) ? "R" : "B";
@@ -146,7 +132,9 @@ export function Roulette(): React.ReactElement {
}
function play(strategy: Strategy): void {
if (reachedLimit()) return;
if (reachedLimit() || !hasEnoughMoney(investment)) {
return;
}
setCanPlay(false);
setLock(false);
@@ -185,15 +173,20 @@ export function Roulette(): React.ReactElement {
setLock(true);
setStatus(status);
setN(n);
reachedLimit();
}, 1600);
}
return (
<>
<Typography variant="h4">{currentNumber()}</Typography>
<TextField type="number" onChange={updateInvestment} placeholder={"Amount to play"} disabled={!canPlay} />
<BetInput
initialBet={initialBet}
maxBet={maxBet}
gameInProgress={!canPlay}
setBet={(bet) => {
setInvestment(bet);
}}
/>
<Typography variant="h4">{status}</Typography>
<table>
<tbody>

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { Player } from "@player";
import { Money } from "../ui/React/Money";
import { BetInput } from "./BetInput";
import { hasEnoughMoney, reachedLimit, win } from "./Game";
import { WHRNG } from "./RNG";
import { win, reachedLimit } from "./Game";
import { trusted } from "./utils";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
// statically shuffled array of symbols.
const symbols = [
@@ -134,14 +134,14 @@ const payLines = [
],
];
const minPlay = 0;
const maxPlay = 1e6;
const initialBet = 1000;
const maxBet = 1e6;
export function SlotMachine(): React.ReactElement {
const [rng] = useState(new WHRNG(Player.totalPlaytime));
const [index, setIndex] = useState<number[]>([0, 0, 0, 0, 0]);
const [locks, setLocks] = useState<number[]>([0, 0, 0, 0, 0]);
const [investment, setInvestment] = useState(1000);
const [investment, setInvestment] = useState(initialBet);
const [canPlay, setCanPlay] = useState(true);
const [status, setStatus] = useState<string | JSX.Element>("waiting");
@@ -187,7 +187,9 @@ export function SlotMachine(): React.ReactElement {
}
function play(): void {
if (reachedLimit()) return;
if (reachedLimit() || !hasEnoughMoney(investment)) {
return;
}
setStatus("playing");
win(-investment);
if (!canPlay) return;
@@ -240,7 +242,6 @@ export function SlotMachine(): React.ReactElement {
</>,
);
setCanPlay(true);
if (reachedLimit()) return;
}
function unlock(): void {
@@ -248,20 +249,6 @@ export function SlotMachine(): React.ReactElement {
setCanPlay(false);
}
function updateInvestment(e: React.ChangeEvent<HTMLInputElement>): void {
let investment: number = parseInt(e.currentTarget.value);
if (isNaN(investment)) {
investment = minPlay;
}
if (investment > maxPlay) {
investment = maxPlay;
}
if (investment < minPlay) {
investment = minPlay;
}
setInvestment(investment);
}
const t = getTable(index, symbols);
// prettier-ignore
return (
@@ -273,16 +260,19 @@ export function SlotMachine(): React.ReactElement {
<Typography sx={{ lineHeight: "1em", whiteSpace: "pre" }}>| | | | | | | |</Typography>
<Typography sx={{ lineHeight: "1em", whiteSpace: "pre" }}>| | {symbols[(index[0]+1)%symbols.length]} | {symbols[(index[1]+1)%symbols.length]} | {symbols[(index[2]+1)%symbols.length]} | {symbols[(index[3]+1)%symbols.length]} | {symbols[(index[4]+1)%symbols.length]} | |</Typography>
<Typography sx={{ lineHeight: "1em", whiteSpace: "pre" }}>++</Typography>
<TextField
type="number"
onChange={updateInvestment}
placeholder={"Amount to play"}
disabled={!canPlay}
InputProps={{endAdornment:(<Button
onClick={trusted(play)}
disabled={!canPlay}
>Spin!</Button>)}}
<BetInput
initialBet={initialBet}
maxBet={maxBet}
gameInProgress={!canPlay}
setBet={(bet) => {
setInvestment(bet);
}}
/>
<div>
<Button onClick={trusted(play)} disabled={!canPlay}>
Spin!
</Button>
</div>
<Typography variant="h4">{status}</Typography>
<Typography>Pay lines</Typography>