From 9949ad9385d8eb6a5b4111e883748ad3ba3af35a Mon Sep 17 00:00:00 2001 From: ZareMate <0.zaremate@gmail.com> Date: Fri, 30 Jan 2026 02:00:47 +0100 Subject: [PATCH] blackjack base --- package-lock.json | 89 +++-- package.json | 2 + src/app/games/blackjack/BlackjackClient.tsx | 354 ++++++++++++++++++++ src/app/games/blackjack/actions.ts | 84 +++++ src/app/games/blackjack/page.tsx | 10 + src/components/new_items.tsx | 2 +- src/lib/blackjack/engine.ts | 41 +++ src/lib/blackjack/types.ts | 0 8 files changed, 562 insertions(+), 20 deletions(-) create mode 100644 src/app/games/blackjack/BlackjackClient.tsx create mode 100644 src/app/games/blackjack/actions.ts create mode 100644 src/app/games/blackjack/page.tsx create mode 100644 src/lib/blackjack/engine.ts create mode 100644 src/lib/blackjack/types.ts diff --git a/package-lock.json b/package-lock.json index d26f5324..e4ff98aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@auth/prisma-adapter": "^2.7.2", "@prisma/client": "^6.6.0", "@t3-oss/env-nextjs": "^0.12.0", + "framer-motion": "^12.29.2", "next": "^15.2.3", "next-auth": "5.0.0-beta.25", "react": "^19.0.0", + "react-casino": "^0.2.6", "react-dom": "^19.0.0", "zod": "^3.24.2" }, @@ -1054,7 +1056,6 @@ "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -1536,7 +1537,6 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1596,7 +1596,6 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -2083,7 +2082,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3063,7 +3061,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3654,6 +3651,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz", + "integrity": "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.29.2", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4459,7 +4483,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4857,7 +4880,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -4933,6 +4955,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", + "integrity": "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5105,7 +5142,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5191,7 +5227,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5514,7 +5549,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5545,7 +5579,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5656,7 +5689,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.2", "@prisma/engines": "6.19.2" @@ -5680,7 +5712,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5752,7 +5783,32 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-casino": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/react-casino/-/react-casino-0.2.6.tgz", + "integrity": "sha512-+ZlU9kV5fBZ6gsyi81Ntd5jWQ17XZwAsIMh4TY+42uN3SZyME00Fu+LuuKS+v8npovvSRK+YKuvoceICdOzVmg==", + "license": "MIT", + "dependencies": { + "react": "^16.8.6" + }, + "peerDependencies": { + "react": "^16.8.6" + } + }, + "node_modules/react-casino/node_modules/react": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, "engines": { "node": ">=0.10.0" } @@ -5762,7 +5818,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5774,7 +5829,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/readdirp": { @@ -6465,7 +6519,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6615,7 +6668,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6852,7 +6904,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8402af1c..c60a5edf 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "@auth/prisma-adapter": "^2.7.2", "@prisma/client": "^6.6.0", "@t3-oss/env-nextjs": "^0.12.0", + "framer-motion": "^12.29.2", "next": "^15.2.3", "next-auth": "5.0.0-beta.25", "react": "^19.0.0", + "react-casino": "^0.2.6", "react-dom": "^19.0.0", "zod": "^3.24.2" }, diff --git a/src/app/games/blackjack/BlackjackClient.tsx b/src/app/games/blackjack/BlackjackClient.tsx new file mode 100644 index 00000000..8682fd99 --- /dev/null +++ b/src/app/games/blackjack/BlackjackClient.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useRef, useState, useTransition } from "react"; +import { startGame, hit, stand, revealDealer } from "./actions"; +import { motion } from "framer-motion"; + +type Phase = "idle" | "playing" | "revealing" | "finished"; + +export default function BlackjackClient() { + const [state, setState] = useState(null); + const [gameId, setGameId] = useState(null); + const [phase, setPhase] = useState("idle"); + const [visibleDealerCount, setVisibleDealerCount] = useState(1); + + const dealerRevealIndex = useRef(0); + const MAX_DEALER_CARDS = 5; + const CARD_WIDTH = 64; // w-16 + const GAP = 8; // space-x-2 + + const [, startTransition] = useTransition(); + + // animation snapshots + const lastPlayerLen = useRef(0); + + /* ---------------- actions ---------------- */ + + const start = async () => { + const res: any = await startGame(10); + lastPlayerLen.current = 0; + setVisibleDealerCount(1); + setGameId(res.gameId); + setPhase("playing"); + setState(res); + }; + + const getDealerCards = () => { + // if we don't yet have any dealer info, nothing to render + if (!state?.dealerUpcard && !state?.dealer) return []; + + // ALWAYS start with upcard + hole slot; prefer the authoritative dealer state when available + const upcard = state?.dealer?.[0] ?? state.dealerUpcard[0]; + const base = [upcard, "__HOLE__"]; + + // If dealer is revealed, append extra cards ONLY + if (state?.dealer && state.dealer.length > 2) { + return [...base, ...state.dealer.slice(2)]; + } + + return base; + }; + + const onHit = async () => { + lastPlayerLen.current = state.player.length; + + const res = await hit(gameId!); + setState((s: any) => ({ ...s, ...res })); + + if (res.status === "bust") { + const dealerRes = await revealDealer(gameId!); + + setState((s: any) => ({ + ...s, + dealer: dealerRes.dealer, + dealerTotal: dealerRes.dealerTotal, + status: "bust", + })); + + setVisibleDealerCount(dealerRes.dealer.length); + setPhase("finished"); + } + }; + + const onStand = async () => { + const res = await stand(gameId!); + + setPhase("revealing"); + // start with only upcard visible; we'll reveal the hole (i=1) then extra cards (i>=2) + dealerRevealIndex.current = 0; + setVisibleDealerCount(1); + + setState((s: any) => ({ + ...s, + dealer: res.dealer, + dealerTotal: res.dealerTotal, + status: "finished", + result: res.result, + })); + + // sequential reveal: + // - i = 1 -> flip the hole card + // - i >= 2 -> reveal each drawn card in order + for (let i = 1; i < res.dealer.length; i++) { + // wait before revealing next slot + await new Promise((r) => setTimeout(r, 1600)); + // set which dealer index should be animating (1 for hole, 2..n for drawn) + dealerRevealIndex.current = i; + // make the slot visible (i+1 slots visible: 0..i) + setVisibleDealerCount(i + 1); + // small pause so the flip animation has time to start/finish before next reveal step + await new Promise((r) => setTimeout(r, 600)); + } + + setPhase("finished"); + }; + + /* ---------------- cards ---------------- */ + + const CardStatic = ({ c }: any) => ( +
+ {c.label} + {c.suit} +
+ ); + + const CardBack = () => ( +
+ RUST +
+ ); + + const CardFlip = ({ c }: any) => ( + + + {/* BACK */} +
+ RUST +
+ + {/* FRONT */} +
+ {c.label} + {c.suit} +
+
+
+ ); + + function isSoftHand(hand: any[], total: number) { + const hasAce = hand.some((c) => c.label === "A"); + if (!hasAce) return false; + + // if counting all aces as 1 would reduce the total, it's soft + const minTotal = hand.reduce( + (sum, c) => sum + (c.label === "A" ? 1 : c.value), + 0, + ); + + return minTotal !== total; + } + + /* Stable key helpers to prevent React remount flashes */ + const dealerCardKey = (slot: any, index: number) => { + // slot can be a card object or "__HOLE__" + if (slot === "__HOLE__") { + // When hole is hidden, use a stable hidden key so it doesn't briefly mount/unmount + if (phase === "playing" || state?.status === "bust") { + return "dealer-hole-hidden"; + } + // When revealed, key should reflect the actual dealer card identity + const revealedCard = state?.dealer?.[1]; + if (revealedCard?.code) return `dealer-${revealedCard.code}`; + if (revealedCard) + return `dealer-${revealedCard.label}${revealedCard.suit}`; + return `dealer-hole-${index}`; + } + + // For upcard and other visible cards prefer a unique code if available + if (slot?.code) return `dealer-${slot.code}`; + if (slot?.label && slot?.suit) + return `dealer-${slot.label}${slot.suit}-${index}`; + + // fallback stable index + return `dealer-${index}`; + }; + + /* ---------------- render ---------------- */ + + return ( +
+
+ {/* DEALER */} +
+ {getDealerCards().map((c, i) => { + // Always render upcard (index 0) regardless of visibleDealerCount + if (i === 0 && c && c !== "__HOLE__") { + const key = dealerCardKey(c, i); + return ; + } + + // HOLE CARD (index 1) + if (i === 1) { + const key = dealerCardKey(c, i); + + // Hidden hole card during play or when player busts + // Also respect visibleDealerCount: hole is visible only when visibleDealerCount >= 2 + if ( + phase === "playing" || + state?.status === "bust" || + visibleDealerCount < 2 + ) { + return ; + } + + // During reveal phase: flip the hole only when dealerRevealIndex === 1 + if (phase === "revealing" && state?.dealer) { + if (dealerRevealIndex.current === 1) { + return ; + } else { + return ; + } + } + + // Final static revealed card + return ; + } + + // DRAWN CARDS (index >= 2) + if (i >= 2 && state?.dealer) { + // only render this slot once it's been made visible by visibleDealerCount + if (i >= visibleDealerCount) return null; + + const key = dealerCardKey(state.dealer[i], i); + + // If we're currently revealing this index, play the flip animation. + // Otherwise show the static face + if (phase === "revealing" && i === dealerRevealIndex.current) { + return ; + } + + return ; + } + + return null; + })} +
+ + {/* TERMINAL */} +
+ {phase === "idle" &&

> INSERT SCRAP

} + + {phase === "playing" && state && ( + <> +

+ > PLAYER TOTAL: {state.playerTotal} + {isSoftHand(state.player, state.playerTotal) && " (SOFT)"} +

+ +

> DEALER WAITING...

+ + )} + + {phase === "revealing" && ( + + > DEALER DRAWING... + + )} + + {phase === "finished" && state && ( + +

> PLAYER: {state.playerTotal}

+ + {state.status === "bust" && ( +

> PLAYER BUSTED

+ )} + + {/* Always show dealer score once dealer is revealed */} + {state.dealerTotal !== undefined && ( +

> DEALER: {state.dealerTotal}

+ )} + + {state.result &&

> RESULT: {state.result.toUpperCase()}

} +
+ )} +
+ {/* PLAYER */} +
+ {state?.player?.map((c: any, i: number) => + phase === "playing" && i >= lastPlayerLen.current ? ( + + ) : ( + + ), + )} +
+ {/* CONTROLS */} +
+ {phase === "idle" && ( + + )} + + {phase === "playing" && state?.status === "playing" && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/src/app/games/blackjack/actions.ts b/src/app/games/blackjack/actions.ts new file mode 100644 index 00000000..0488cb61 --- /dev/null +++ b/src/app/games/blackjack/actions.ts @@ -0,0 +1,84 @@ +"use server"; + +import crypto from "crypto"; +import { + createDeck, + shuffle, + calculateHand, + type Card, +} from "~/lib/blackjack/engine"; + +// In-memory storage (replace with DB/Redis in production) +const games = new Map(); + +export async function startGame(bet: number) { + const deck = shuffle(createDeck()); + + const player: Card[] = [deck.pop()!, deck.pop()!]; + const dealer: Card[] = [deck.pop()!, deck.pop()!]; + + const gameId = crypto.randomUUID(); + + games.set(gameId, { deck, player, dealer, bet, status: "playing" }); + + return { + gameId, + player, + dealerUpcard: [dealer[0]], + playerTotal: calculateHand(player), + status: "playing", + }; +} + +export async function hit(gameId: string) { + const game = games.get(gameId); + if (!game) throw new Error("Game not found"); + + const card = game.deck.pop(); + game.player.push(card); + + const playerTotal = calculateHand(game.player); + if (playerTotal > 21) game.status = "bust"; + + return { + player: game.player, + playerTotal, + status: game.status, + }; +} + +export async function stand(gameId: string) { + const game = games.get(gameId); + if (!game) throw new Error("Game not found"); + + while (calculateHand(game.dealer) < 17) { + game.dealer.push(game.deck.pop()); + } + + const playerTotal = calculateHand(game.player); + const dealerTotal = calculateHand(game.dealer); + + let result: "win" | "lose" | "push" = "lose"; + if (dealerTotal > 21 || playerTotal > dealerTotal) result = "win"; + if (playerTotal === dealerTotal) result = "push"; + + // TODO: update user balance here + + games.delete(gameId); + + return { + dealer: game.dealer, + dealerTotal, + result, + }; +} + +export async function revealDealer(gameId: string) { + const game = games.get(gameId); + if (!game) throw new Error("Game not found"); + + return { + dealer: game.dealer, + dealerTotal: calculateHand(game.dealer), + }; +} diff --git a/src/app/games/blackjack/page.tsx b/src/app/games/blackjack/page.tsx new file mode 100644 index 00000000..8b1d79f9 --- /dev/null +++ b/src/app/games/blackjack/page.tsx @@ -0,0 +1,10 @@ +import BlackjackClient from "./BlackjackClient"; + +export default function BlackjackPage() { + return ( +
+

Blackjack

+ +
+ ); +} diff --git a/src/components/new_items.tsx b/src/components/new_items.tsx index 20821604..d349305e 100644 --- a/src/components/new_items.tsx +++ b/src/components/new_items.tsx @@ -231,7 +231,7 @@ export default function SellableItemsButton({ (acc, s) => { if (!userShopIds.has(s.shopId)) return acc; acc[s.shopId] ??= []; - acc[s.shopId].push(s); + acc[s.shopId]!.push(s); return acc; }, {}, diff --git a/src/lib/blackjack/engine.ts b/src/lib/blackjack/engine.ts new file mode 100644 index 00000000..5a6a0478 --- /dev/null +++ b/src/lib/blackjack/engine.ts @@ -0,0 +1,41 @@ +import crypto from "crypto"; + +export type Card = { + suit: "♠" | "♥" | "♦" | "♣"; + value: number; + label: string; +}; + +const suits = ["♠", "♥", "♦", "♣"] as const; +const labels = [ + { label: "A", value: 11 }, + { label: "2", value: 2 }, + { label: "3", value: 3 }, + { label: "4", value: 4 }, + { label: "5", value: 5 }, + { label: "6", value: 6 }, + { label: "7", value: 7 }, + { label: "8", value: 8 }, + { label: "9", value: 9 }, + { label: "10", value: 10 }, + { label: "J", value: 10 }, + { label: "Q", value: 10 }, + { label: "K", value: 10 }, +]; + +export function createDeck(): Card[] { + return suits.flatMap((suit) => labels.map((l) => ({ suit, ...l }))); +} + +export function shuffle(deck: Card[]) { + return deck.sort(() => (crypto.randomInt(0, 2) === 0 ? -1 : 1)); +} + +export function calculateHand(hand: Card[]): number { + let total = hand.reduce((sum, c) => sum + c.value, 0); + let aces = hand.filter((c) => c.label === "A").length; + + while (total > 21 && aces--) total -= 10; + + return total; +} diff --git a/src/lib/blackjack/types.ts b/src/lib/blackjack/types.ts new file mode 100644 index 00000000..e69de29b