Compare commits
No commits in common. "f5886728949ec1e563cffe4caf81616d370d5c3b" and "f5c581000e8be1545c2e9b53d844b7fd96e0b506" have entirely different histories.
f588672894
...
f5c581000e
89
package-lock.json
generated
89
package-lock.json
generated
@ -12,11 +12,9 @@
|
||||
"@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"
|
||||
},
|
||||
@ -1056,6 +1054,7 @@
|
||||
"integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
@ -1537,6 +1536,7 @@
|
||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -1596,6 +1596,7 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@ -2082,6 +2083,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -3061,6 +3063,7 @@
|
||||
"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",
|
||||
@ -3651,33 +3654,6 @@
|
||||
"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",
|
||||
@ -4483,6 +4459,7 @@
|
||||
"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": {
|
||||
@ -4880,6 +4857,7 @@
|
||||
"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"
|
||||
@ -4955,21 +4933,6 @@
|
||||
"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",
|
||||
@ -5142,6 +5105,7 @@
|
||||
"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"
|
||||
@ -5227,6 +5191,7 @@
|
||||
"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"
|
||||
@ -5549,6 +5514,7 @@
|
||||
"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"
|
||||
@ -5579,6 +5545,7 @@
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@ -5689,6 +5656,7 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.2",
|
||||
"@prisma/engines": "6.19.2"
|
||||
@ -5712,6 +5680,7 @@
|
||||
"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",
|
||||
@ -5783,32 +5752,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"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"
|
||||
},
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -5818,6 +5762,7 @@
|
||||
"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"
|
||||
},
|
||||
@ -5829,6 +5774,7 @@
|
||||
"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": {
|
||||
@ -6519,6 +6465,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -6668,6 +6615,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -6904,6 +6852,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@ -10,25 +10,23 @@
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"dev": "next dev --turbo -p 3100",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"postinstall": "prisma generate",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"preview": "next build && next start -p 3100",
|
||||
"start": "next start -p 3100",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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"
|
||||
},
|
||||
|
||||
@ -1,354 +0,0 @@
|
||||
"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<any>(null);
|
||||
const [gameId, setGameId] = useState<string | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("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) => (
|
||||
<div className="flex h-24 w-16 items-center justify-center rounded bg-white text-xl font-bold text-black shadow-xl">
|
||||
{c.label}
|
||||
{c.suit}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CardBack = () => (
|
||||
<div className="flex h-24 w-16 items-center justify-center rounded bg-zinc-700 shadow-xl">
|
||||
<span className="text-xs tracking-widest text-zinc-300">RUST</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CardFlip = ({ c }: any) => (
|
||||
<motion.div
|
||||
className="perspective h-24 w-16"
|
||||
initial={{ y: -60, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 1.0 }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative h-full w-full"
|
||||
initial={{ rotateY: 0 }}
|
||||
animate={{ rotateY: 180 }}
|
||||
transition={{
|
||||
duration: 1.0,
|
||||
delay: 0.8,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
style={{ transformStyle: "preserve-3d" }}
|
||||
>
|
||||
{/* BACK */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center rounded bg-zinc-700 shadow-xl"
|
||||
style={{ backfaceVisibility: "hidden" }}
|
||||
>
|
||||
<span className="text-xs tracking-widest text-zinc-300">RUST</span>
|
||||
</div>
|
||||
|
||||
{/* FRONT */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center rounded bg-white text-xl font-bold text-black shadow-xl"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
transform: "rotateY(180deg)",
|
||||
}}
|
||||
>
|
||||
{c.label}
|
||||
{c.suit}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-900 text-white">
|
||||
<div className="w-[420px] space-y-4 rounded-lg bg-zinc-800 p-4 shadow-2xl">
|
||||
{/* DEALER */}
|
||||
<div className="flex min-h-[96px] justify-center space-x-2">
|
||||
{getDealerCards().map((c, i) => {
|
||||
// Always render upcard (index 0) regardless of visibleDealerCount
|
||||
if (i === 0 && c && c !== "__HOLE__") {
|
||||
const key = dealerCardKey(c, i);
|
||||
return <CardStatic key={key} c={c} />;
|
||||
}
|
||||
|
||||
// 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 <CardBack key={key} />;
|
||||
}
|
||||
|
||||
// During reveal phase: flip the hole only when dealerRevealIndex === 1
|
||||
if (phase === "revealing" && state?.dealer) {
|
||||
if (dealerRevealIndex.current === 1) {
|
||||
return <CardFlip key={key} c={state.dealer[1]} />;
|
||||
} else {
|
||||
return <CardStatic key={key} c={state.dealer[1]} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Final static revealed card
|
||||
return <CardStatic key={key} c={state.dealer?.[1]} />;
|
||||
}
|
||||
|
||||
// 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 <CardFlip key={key} c={state.dealer[i]} />;
|
||||
}
|
||||
|
||||
return <CardStatic key={key} c={state.dealer[i]} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* TERMINAL */}
|
||||
<div className="min-h-[130px] rounded border-2 border-green-500 bg-black p-3 font-mono text-sm text-green-400">
|
||||
{phase === "idle" && <p>> INSERT SCRAP</p>}
|
||||
|
||||
{phase === "playing" && state && (
|
||||
<>
|
||||
<p>
|
||||
> PLAYER TOTAL: {state.playerTotal}
|
||||
{isSoftHand(state.player, state.playerTotal) && " (SOFT)"}
|
||||
</p>
|
||||
|
||||
<p>> DEALER WAITING...</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "revealing" && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1 }}
|
||||
>
|
||||
> DEALER DRAWING...
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{phase === "finished" && state && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1 }}
|
||||
>
|
||||
<p>> PLAYER: {state.playerTotal}</p>
|
||||
|
||||
{state.status === "bust" && (
|
||||
<p className="text-red-500">> PLAYER BUSTED</p>
|
||||
)}
|
||||
|
||||
{/* Always show dealer score once dealer is revealed */}
|
||||
{state.dealerTotal !== undefined && (
|
||||
<p>> DEALER: {state.dealerTotal}</p>
|
||||
)}
|
||||
|
||||
{state.result && <p>> RESULT: {state.result.toUpperCase()}</p>}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
{/* PLAYER */}
|
||||
<div className="flex min-h-[96px] justify-center space-x-2">
|
||||
{state?.player?.map((c: any, i: number) =>
|
||||
phase === "playing" && i >= lastPlayerLen.current ? (
|
||||
<CardFlip
|
||||
key={`player-${c?.code ?? c?.label + c?.suit + i}`}
|
||||
c={c}
|
||||
/>
|
||||
) : (
|
||||
<CardStatic
|
||||
key={`player-${c?.code ?? c?.label + c?.suit + i}`}
|
||||
c={c}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{/* CONTROLS */}
|
||||
<div className="flex justify-center space-x-3">
|
||||
{phase === "idle" && (
|
||||
<button
|
||||
onClick={() => startTransition(start)}
|
||||
className="rounded bg-green-700 px-4 py-2 font-bold"
|
||||
>
|
||||
INSERT SCRAP
|
||||
</button>
|
||||
)}
|
||||
|
||||
{phase === "playing" && state?.status === "playing" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startTransition(onHit)}
|
||||
className="rounded bg-green-700 px-4 py-2"
|
||||
>
|
||||
HIT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startTransition(onStand)}
|
||||
className="rounded bg-red-700 px-4 py-2"
|
||||
>
|
||||
STAND
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
"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<string, any>();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import BlackjackClient from "./BlackjackClient";
|
||||
|
||||
export default function BlackjackPage() {
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<h1 className="text-3xl font-bold">Blackjack</h1>
|
||||
<BlackjackClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
{},
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user