diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml index 5be3994..e5d6491 100644 --- a/.github/actions/deploy/action.yml +++ b/.github/actions/deploy/action.yml @@ -7,6 +7,12 @@ inputs: piesocket-api-key: description: "PieSocket API Key" required: true + base-url: + description: "Base URL for WebApp" + required: true + contracts-addr: + description: "Contracts Address" + required: true outputs: cloudflare-preview-url: description: "Couldflare Preview URL" @@ -20,6 +26,8 @@ runs: shell: bash env: PIESOCKET_API_KEY: ${{ inputs.piesocket-api-key }} + NEXT_PUBLIC_BASE_URL: ${{ inputs.base-url }} + NEXT_PUBLIC_CONTRACTS_ADDR: ${{ inputs.contracts-addr }} run: npm run build - name: Deploy To Cloudflare id: deploy diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index f8f8284..7e9e824 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,10 +6,15 @@ runs: - name: Cache Turbo Build Setup uses: actions/cache@v4 with: - path: .turbo + path: | + ~/.cargo + .turbo key: ${{ runner.os }}-release-job-${{ github.sha }} restore-keys: | ${{ runner.os }}-release-job- + - name: Insall circom + shell: bash + run: cargo install --git https://github.com/iden3/circom.git --tag v2.1.9 - name: Setup Node.js Environment uses: actions/setup-node@v4 with: @@ -17,4 +22,4 @@ runs: cache: "npm" - name: Install Dependencies shell: bash - run: npm install + run: npm ci diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d04faf5..2fdf84b 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -58,6 +58,8 @@ jobs: with: cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }} piesocket-api-key: ${{ secrets.PIESOCKET_API_KEY }} + base-url: ${{ secrets.BASE_URL }} + contracts-addr: ${{ secrets.CONTRACTS_ADDR }} - name: Comment Preview URL uses: actions/github-script@v7 if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index 148f8de..47b5915 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,7 @@ dist # Misc .DS_Store *.pem + +# Aptos +.aptos + diff --git a/README.md b/README.md index d7d5eff..49eb660 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# jeton +# Jeton: A Decentralized and Trustless Poker Platform -Decentralized poker protocol enabling trustless, transparent, and community-governed gameplay – powered by JatonDAO. +Jeton is a decentralized poker platform designed to ensure fairness and transparency in online poker games. Built on the Aptos blockchain, Jeton eliminates the need for players to trust a central authority by leveraging zk-SNARKs (Zero-Knowledge Succinct Non-Interactive Arguments of Knowledge) and Elgamal encryption. These cryptographic techniques guarantee that the cards are shuffled, encrypted, and dealt fairly without revealing any information to players or the platform itself. + +The platform utilizes Elgamal encryption on the JubJub elliptic curve to securely encrypt cards and zk-SNARK circuits to verify both the shuffle and decryption processes. Each player participates in shuffling the deck and generating decryption shares, ensuring no individual player or entity can manipulate the outcome. Smart contracts on the Aptos blockchain handle the game logic and verify cryptographic proofs, providing a fully decentralized and tamper-proof environment for online poker. + +For a more detailed explanation of the algorithms, security considerations, and cryptographic methods used in Jeton, you can read the [full project overview](project-overview.md). ## What's inside? @@ -8,8 +12,12 @@ This Turborepo includes the following packages/apps: ### Apps and Packages -- `web`: a [Next.js](https://nextjs.org/) app +- `web`: the web application of the jeton protocol, implemented in [Next.js](https://nextjs.org/) +- `@jeton/zk-deck`: a package containing Move, TypeScript, and Circom code, for implementing zero knowledge based playing decks +- `@jeton/smart-contracts`: a Move package responsible for on chain game logic +- `@jeton/ts-sdk`: - `@jeton/ui`: a React component library shared by `web` application +- `@jeton/tailwindcss-config`: - `@jeton/typescript-config`: `tsconfig.json`s used throughout the monorepo Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). @@ -30,7 +38,17 @@ Make sure you have the correct versions of Node.js and npm installed: - **Node.js**: v20.16 or later - **npm**: v10.8 or later -You can use [NVM](https://github.com/nvm-sh/nvm) to install the required version of node and npm. +You can use [NVM](https://github.com/nvm-sh/nvm) to install the required version +of node and npm. + +Also you need rustc and cargo install. It is recommended to use +[rustup](https://rustup.rs/) to install them. + +After that you need to also install circom using: + +```shell +cargo install --git https://github.com/iden3/circom.git --tag v2.1.9 +``` ### Cloning the Repository diff --git a/apps/web/.gitignore b/apps/web/.gitignore index f886745..53fe5b6 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +#generated service worker file +public/sw.js \ No newline at end of file diff --git a/apps/web/addCacheUrls.mts b/apps/web/addCacheUrls.mts new file mode 100644 index 0000000..362deb9 --- /dev/null +++ b/apps/web/addCacheUrls.mts @@ -0,0 +1,11 @@ +import { readFileSync, writeFileSync } from "fs"; +import { decryptCardShareZkey, shuffleEncryptDeckZkey } from "@jeton/zk-deck"; + +const SWFile = readFileSync("./src/sw.template.js", "utf-8"); + +const modifiedSWFile = SWFile.replace( + '""', + `"${decryptCardShareZkey}", "${shuffleEncryptDeckZkey}"`, +); + +writeFileSync("./public/sw.js", modifiedSWFile, "utf-8"); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 044a737..4420edf 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,7 +1,31 @@ import { setupDevPlatform } from "@cloudflare/next-on-pages/next-dev"; /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + webpack(config) { + config.module.rules.push({ + test: /\.(mp3|ogg)$/i, + use: [ + { + loader: "file-loader", + options: { + name: "[path][name].[ext]", + publicPath: "/_next/static/audio/", + outputPath: "static/audio/", + }, + }, + ], + }); + config.module.rules.push({ + test: /\.wasm/, + type: "asset/resource", + }); + // config.externals.push({ + // "node:crypto": "crypto", + // }); + return config; + }, +}; if (process.env.NODE_ENV === "development") { await setupDevPlatform(); diff --git a/apps/web/package.json b/apps/web/package.json index 4a3dcde..ce2a275 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,19 +4,30 @@ "private": true, "scripts": { "dev": "next dev", + "dev:service-worker": "tsx ./addCacheUrls.mts", + "build:service-worker": "tsx ./addCacheUrls.mts", "build": "next build", "next-on-pages": "npx @cloudflare/next-on-pages", "start": "next start" }, "dependencies": { - "@jeton/ts-sdk": "*", - "@jeton/ui": "*", + "@aptos-labs/ts-sdk": "^1.29.1", "@aptos-labs/wallet-adapter-ant-design": "^3.0.13", "@aptos-labs/wallet-adapter-react": "^3.6.2", + "@jeton/ts-sdk": "*", + "@jeton/ui": "*", + "@jeton/zk-deck": "*", "@legendapp/state": "^3.0.0-alpha.29", + "clsx": "^2.1.1", + "framer-motion": "^11.6.0", + "graphql": "^16.9.0", + "graphql-request": "^7.1.0", + "nes.css": "^2.2.1", "next": "^14.2.8", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2", + "tsx": "^4.19.1" }, "devDependencies": { "@cloudflare/next-on-pages": "^1.13.2", @@ -28,6 +39,7 @@ "@types/react-dom": "^18", "autoprefixer": "^10.4.20", "postcss": "^8.4.41", + "postcss-import": "^16.1.0", "tailwindcss": "^3.4.10", "typescript": "^5" } diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js index 12a703d..237783c 100644 --- a/apps/web/postcss.config.js +++ b/apps/web/postcss.config.js @@ -1,6 +1,7 @@ module.exports = { plugins: { - tailwindcss: {}, - autoprefixer: {}, + "postcss-import": {}, // Import CSS files first + tailwindcss: {}, // Then apply TailwindCSS + autoprefixer: {}, // Lastly, add vendor prefixes }, }; diff --git a/apps/web/public/aptosconnect.txt b/apps/web/public/aptosconnect.txt new file mode 100644 index 0000000..8c9558b --- /dev/null +++ b/apps/web/public/aptosconnect.txt @@ -0,0 +1 @@ +b76e8b1d-3fe8-442a-b254-47b2dcff3f2a \ No newline at end of file diff --git a/apps/web/public/images/card-back.png b/apps/web/public/images/card-back.png new file mode 100644 index 0000000..2b084d3 Binary files /dev/null and b/apps/web/public/images/card-back.png differ diff --git a/apps/web/public/images/logo.png b/apps/web/public/images/logo.png new file mode 100644 index 0000000..9e148e3 Binary files /dev/null and b/apps/web/public/images/logo.png differ diff --git a/apps/web/public/images/pixel-wooden-pattern.png b/apps/web/public/images/pixel-wooden-pattern.png new file mode 100644 index 0000000..443d1f4 Binary files /dev/null and b/apps/web/public/images/pixel-wooden-pattern.png differ diff --git a/apps/web/public/images/wood-pattern-light.png b/apps/web/public/images/wood-pattern-light.png new file mode 100644 index 0000000..d65b605 Binary files /dev/null and b/apps/web/public/images/wood-pattern-light.png differ diff --git a/apps/web/public/mizuwallet-connect-manifest.json b/apps/web/public/mizuwallet-connect-manifest.json new file mode 100644 index 0000000..626378f --- /dev/null +++ b/apps/web/public/mizuwallet-connect-manifest.json @@ -0,0 +1,5 @@ +{ + "url": "https://dev.jeton.pages.dev", + "name": "Test Login with mizu", + "iconUrl": "https://dev.jeton.pages.dev/images/logo.png" +} diff --git a/apps/web/public/register-service-worker.js b/apps/web/public/register-service-worker.js new file mode 100644 index 0000000..7efef5a --- /dev/null +++ b/apps/web/public/register-service-worker.js @@ -0,0 +1,11 @@ +function registerServiceWorker() { + if (typeof window !== "undefined") { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js").catch((e) => { + console.log("service worker installation error", e); + }); + } + } +} + +registerServiceWorker(); diff --git a/apps/web/src/app/@modal/(.)create/page.tsx b/apps/web/src/app/@modal/(.)create/page.tsx new file mode 100644 index 0000000..454e136 --- /dev/null +++ b/apps/web/src/app/@modal/(.)create/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useWallet } from "@aptos-labs/wallet-adapter-react"; +import { ChipUnits, type TableInfo } from "@jeton/ts-sdk"; +import Modal from "@src/components/Modal"; +import { usePathname, useRouter } from "next/navigation"; +import React, { useState, type ChangeEvent, type FormEvent, useContext, useEffect } from "react"; + +export const runtime = "edge"; + +type FormValues = Omit; + +import { decryptCardShareZkey, shuffleEncryptDeckZkey } from "@jeton/zk-deck"; +//@ts-ignore +import decryptCardShareWasm from "@jeton/zk-deck/wasm/decrypt-card-share.wasm"; +//@ts-ignore +import shuffleEncryptDeckWasm from "@jeton/zk-deck/wasm/shuffle-encrypt-deck.wasm"; +import CheckIn from "@src/components/CheckIn"; +import { Input } from "@src/components/Input"; +import { JetonContext } from "@src/components/JetonContextProvider"; +import useCheckIn from "@src/hooks/useCheckIn"; +import type { SignAndSubmitTransaction } from "@src/types/SignAndSubmitTransaction"; +import { finalAddressAndSignFunction } from "@src/utils/inAppWallet"; + +const INITIAL_FORM_VALUES: FormValues = { + smallBlind: 1, + numberOfRaises: 2, + minPlayers: 2, + minBuyIn: 100, + maxBuyIn: 1000, + maxPlayers: 9, + waitingTimeout: 3600, + chipUnit: ChipUnits.apt, +}; + +const INPUT_FIELDS = [ + { label: "Small Blind", name: "smallBlind" }, + { label: "Number of Raises", name: "numberOfRaises" }, + { label: "Minimum Players", name: "minPlayers" }, + { label: "Minimum Buy-in", name: "minBuyIn" }, + { label: "Maximum Buy-in", name: "maxBuyIn" }, +]; + +export default function GameCreateModal() { + const [loading, setLoading] = useState(false); + const [formValues, setFormValues] = useState(INITIAL_FORM_VALUES); + const { account, signAndSubmitTransaction } = useWallet(); + const { checkIn, submitCheckIn } = useCheckIn(); + const [isCreating, setIsCreating] = useState(false); + + const { createTable } = useContext(JetonContext); + const router = useRouter(); + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setFormValues((prev) => ({ + ...prev, + [name]: Number.parseInt(value), + })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setLoading(true); + + try { + if (!createTable) throw new Error("create Table must exist"); + if (!checkIn) throw new Error("Check in you must do"); + + const TIMEOUT = 300; // 5 minutes + const [finalAddress, finalSignAndSubmit] = finalAddressAndSignFunction( + account!.address, + signAndSubmitTransaction as SignAndSubmitTransaction, + ); + + const jeton = await createTable( + formValues.smallBlind, + formValues.numberOfRaises, + formValues.minPlayers, + formValues.minBuyIn, + formValues.maxBuyIn, + TIMEOUT, + formValues.chipUnit, + checkIn, + finalAddress, + finalSignAndSubmit, + { + decryptCardShareWasm, + shuffleEncryptDeckWasm, + decryptCardShareZkey, + shuffleEncryptDeckZkey, + }, + ); + + router.push(`/games/${jeton.tableInfo.id}`); + } finally { + setLoading(false); + } + }; + + return ( + + {isCreating ? ( +
+
Create a New Game
+
+ {INPUT_FIELDS.map(({ label, name }) => ( + + ))} +
+ +
+ ) : ( + + submitCheckIn(value, () => { + setIsCreating(true); + }) + } + /> + )} +
+ ); +} diff --git a/apps/web/src/app/@modal/(.)join/page.tsx b/apps/web/src/app/@modal/(.)join/page.tsx new file mode 100644 index 0000000..3ff1942 --- /dev/null +++ b/apps/web/src/app/@modal/(.)join/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +export const runtime = "edge"; + +import { AptosOnChainDataSource, type TableInfo } from "@jeton/ts-sdk"; +import CheckIn from "@src/components/CheckIn"; +import Modal from "@src/components/Modal"; +import Spinner from "@src/components/Spinner"; +import useCheckIn from "@src/hooks/useCheckIn"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function GameJoinModal() { + const [gameTables, setGameTables] = useState([]); + const pathname = usePathname(); + const [loading, setLoading] = useState(true); + const { checkIn, submitCheckIn } = useCheckIn(); + const [isJoining, setIsJoining] = useState(false); + + useEffect(() => { + if (!isJoining) return; + + const fetchTables = async () => { + try { + if (!checkIn) throw new Error("You shall check in my friend"); + const data = await AptosOnChainDataSource.getTablesInfo(); + const tables = data.filter((table) => { + return table.maxBuyIn >= checkIn && table.minBuyIn <= checkIn; + }); + setGameTables(tables); + } finally { + setLoading(false); + } + }; + fetchTables(); + }, [isJoining, checkIn]); + + if (!pathname.includes("join")) { + return null; + } + + // Modal choose violence and did not close on navigating + if (!pathname.includes("join")) { + return null; + } + + return ( + + {isJoining ? ( + <> +
Join a game
+ {gameTables.length > 0 ? ( +
    + {gameTables.map((table) => ( +
  • +
    + Table ID: {table.id.slice(0, 8)} +
    +
    + Small Blind: {table.smallBlind} +
    +
    + Number of Raises: {table.numberOfRaises} +
    +
    + Players: {table.minPlayers} -{" "} + {table.maxPlayers} +
    +
    + Buy-In: {table.minBuyIn} -{" "} + {table.maxBuyIn} +
    +
    + Chip Unit: {table.chipUnit} +
    + + join + +
  • + ))} +
+ ) : loading ? ( +
+ + Loading tables +
+ ) : ( +
No tables available
+ )}{" "} + + ) : ( + + submitCheckIn(value, () => { + setIsJoining(true); + }) + } + /> + )} +
+ ); +} diff --git a/apps/web/src/app/@modal/(.)login/page.tsx b/apps/web/src/app/@modal/(.)login/page.tsx deleted file mode 100644 index 4d841e3..0000000 --- a/apps/web/src/app/@modal/(.)login/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export const runtime = "edge"; -import Modal from "@jeton/ui/Modal"; -import WalletAdapterButton from "@src/components/WalletAdapterButton"; - -export default function LoginModal() { - return ( - - - - ); -} diff --git a/apps/web/src/app/create/page.tsx b/apps/web/src/app/create/page.tsx new file mode 100644 index 0000000..92907f9 --- /dev/null +++ b/apps/web/src/app/create/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function CreatePage() { + const router = useRouter(); + + useEffect(() => { + router.push("/games"); + }, [router]); + + return null; +} diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico index 718d6fe..0d35266 100644 Binary files a/apps/web/src/app/favicon.ico and b/apps/web/src/app/favicon.ico differ diff --git a/apps/web/src/app/games/[id]/components/Card.tsx b/apps/web/src/app/games/[id]/components/Card.tsx new file mode 100644 index 0000000..71287a6 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/Card.tsx @@ -0,0 +1,41 @@ +import type { CardName } from "@src/types"; +import { loadCardImage } from "@src/utils/cardLoader"; +import { cn } from "@src/utils/cn"; +import type { StaticImageData } from "next/image"; +import Image from "next/image"; +import { useEffect, useState } from "react"; + +export default function Card({ + cardName, + className, +}: { + cardName: CardName; + className?: string; +}) { + const [cardSrc, setCardSrc] = useState(null); + + useEffect(() => { + const fetchCardImage = async () => { + try { + const image = await loadCardImage(cardName); + setCardSrc(image); + } catch (error) { + console.error(error); + setCardSrc(null); + } + }; + + fetchCardImage(); + }, [cardName]); + + if (!cardSrc) return null; + + return ( + {cardName} + ); +} diff --git a/apps/web/src/app/games/[id]/components/DealerBadge.tsx b/apps/web/src/app/games/[id]/components/DealerBadge.tsx new file mode 100644 index 0000000..59210c3 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/DealerBadge.tsx @@ -0,0 +1,7 @@ +export default function DealerBadge() { + return ( + + D + + ); +} diff --git a/apps/web/src/app/games/[id]/components/DownloadModal.tsx b/apps/web/src/app/games/[id]/components/DownloadModal.tsx new file mode 100644 index 0000000..a4878b8 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/DownloadModal.tsx @@ -0,0 +1,32 @@ +import { useWallet } from "@aptos-labs/wallet-adapter-react"; +import { useSelector } from "@legendapp/state/react"; +import Modal from "@src/components/Modal"; +import React from "react"; +import { selectIsGameLoading$, selectProgressPercentage$ } from "../state/selectors/gameSelectors"; + +export default function DownloadModal() { + const { isLoading: isWalletLoading } = useWallet(); + const percentage = useSelector(selectProgressPercentage$()); + const isLoading = useSelector(selectIsGameLoading$()) || isWalletLoading; + + if (isLoading) + return ( + +
+ {percentage ? ( + <> + Downloading assets + + {`%${percentage}`} + + ) : ( + "Starting downloading assets..." + )} +
+
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/GameContainer.tsx b/apps/web/src/app/games/[id]/components/GameContainer.tsx new file mode 100644 index 0000000..a0c61f0 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/GameContainer.tsx @@ -0,0 +1,11 @@ +import type { PropsWithChildren } from "react"; + +export default function GameContainer({ children }: PropsWithChildren) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/GameStatusBox.tsx b/apps/web/src/app/games/[id]/components/GameStatusBox.tsx new file mode 100644 index 0000000..1805db0 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/GameStatusBox.tsx @@ -0,0 +1,47 @@ +import { GameStatus } from "@jeton/ts-sdk"; +import { useSelector } from "@legendapp/state/react"; +import { motion } from "framer-motion"; +import { + selectAwaitingBetFrom$, + selectGameStatus$, + selectMyCards$, + selectShufflingPlayer$, +} from "../state/selectors/gameSelectors"; +import LogsButton from "./LogsSidebar"; + +export default function GameStatusBox() { + const gameStatus = useSelector(selectGameStatus$()); + const shufflingPlayer = useSelector(selectShufflingPlayer$()); + const awaitingBetFrom = useSelector(selectAwaitingBetFrom$()); + const myCards = useSelector(selectMyCards$()); + + let statusMessage = "Waiting for players..."; + + if (shufflingPlayer?.id) { + statusMessage = `Shuffling... ${shufflingPlayer.id.slice(2, 8)}'s turn to shuffle`; + } else if (gameStatus === GameStatus.DrawPrivateCards) { + statusMessage = "Dealing private cards..."; + } else if (awaitingBetFrom?.id) { + statusMessage = `Awaiting bet from ${awaitingBetFrom.id.slice(2, 8)}`; + } else if (gameStatus === GameStatus.BetPreFlop && myCards && myCards?.length > 0) { + statusMessage = "Private cards dealt. Ready for Pre-Flop betting."; + } else if (gameStatus) { + statusMessage = `Current status: ${gameStatus}`; + } + + return ( +
+ + +

{statusMessage}

+
+
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/LogsSidebar.tsx b/apps/web/src/app/games/[id]/components/LogsSidebar.tsx new file mode 100644 index 0000000..ce465e5 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/LogsSidebar.tsx @@ -0,0 +1,68 @@ +import { useWallet } from "@aptos-labs/wallet-adapter-react"; +import CloseIcon from "@src/assets/icons/close.svg"; +import { finalAddress } from "@src/utils/inAppWallet"; +import Image from "next/image"; +import { useState } from "react"; + +export default function LogsButton() { + const { account } = useWallet(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const handleToggleSidebar = () => { + setIsSidebarOpen(!isSidebarOpen); + }; + + const address = finalAddress(account?.address || ""); + const isInAppWallet = address !== account?.address; + const logs: { link: string; description: string }[] = []; + + return ( +
+ + +
+
+

Game Logs

+ +
+
+

+ You are using {isInAppWallet ? "our in app wallet" : "your own wallet"} with the address + of: {address} +

+ + {logs.map((log, index) => ( +
+

+ {index + 1}. {log.description} +

+ {log.link && ( + + Transaction + + )} +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/PlayerActions.tsx b/apps/web/src/app/games/[id]/components/PlayerActions.tsx new file mode 100644 index 0000000..22630c5 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/PlayerActions.tsx @@ -0,0 +1,113 @@ +import { useWallet } from "@aptos-labs/wallet-adapter-react"; +import { PlacingBettingActions } from "@jeton/ts-sdk"; +import { useSelector } from "@legendapp/state/react"; +import { JetonContext } from "@src/components/JetonContextProvider"; +import { CARDS_MAP } from "@src/lib/constants/cards"; +import { mockMyCards } from "@src/lib/constants/mocks"; +import { finalAddress } from "@src/utils/inAppWallet"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { + selectAvailableActions$, + selectAwaitingBetFrom$, + selectGamePlayers$, + selectMyCards$, +} from "../state/selectors/gameSelectors"; +import Card from "./Card"; + +export default function PlayerActions() { + const availableActions = useSelector(selectAvailableActions$()); + const awaitingBetFrom = useSelector(selectAwaitingBetFrom$()); + const { game } = useContext(JetonContext); + const players = useSelector(selectGamePlayers$()); + const myCards = useSelector(selectMyCards$()); + + const [queuedAction, setQueuedAction] = useState(null); + const [isActionQueued, setIsActionQueued] = useState(false); + const { account } = useWallet(); + const [address, setInAppAddress] = useState(); + const mainPlayer = useMemo(() => { + return players?.find((player) => player?.id === address); + }, [players, address]); + const isPlayerTurn = awaitingBetFrom?.id === mainPlayer?.id; + const actions: PlacingBettingActions[] = [ + PlacingBettingActions.FOLD, + PlacingBettingActions.CHECK_CALL, + PlacingBettingActions.RAISE, + ]; + + useEffect(() => { + setInAppAddress(finalAddress(account?.address || "")); + }, [account]); + + useEffect(() => { + if (isPlayerTurn && queuedAction) { + console.log(`Player turn, executing queued action: ${queuedAction}`); + handlePlayerAction(queuedAction); + clearQueue(); + } + }, [isPlayerTurn, queuedAction]); + + const handlePlayerAction = (action: PlacingBettingActions) => { + console.log(isPlayerTurn, awaitingBetFrom, mainPlayer); + + if (isPlayerTurn) { + console.log(`Executing action immediately: ${action}`); + takePlayerAction(action); + } else { + console.log(`Queueing action: ${action}`); + setQueuedAction(action); + setIsActionQueued(true); + } + }; + + const clearQueue = () => { + setQueuedAction(null); + setIsActionQueued(false); + }; + + const takePlayerAction = (action: PlacingBettingActions) => { + console.log(`Action taken: ${action}`); + if (!game) throw new Error("Must exist by now"); + game.placeBet(action); + }; + + return ( +
+ {availableActions && + availableActions.length > 0 && + actions.map((action) => ( + + ))} +
+ {myCards?.map( + (cardName, i) => + CARDS_MAP[cardName] && ( + + ), + )} +
+
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/Pot.tsx b/apps/web/src/app/games/[id]/components/Pot.tsx new file mode 100644 index 0000000..e25b986 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/Pot.tsx @@ -0,0 +1,112 @@ +import { useSelector } from "@legendapp/state/react"; +import chips from "@src/assets/images/chips/chips-3-stacks.png"; +import Image from "next/image"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { selectGameStatus$, selectPot$ } from "../state/selectors/gameSelectors"; + +import { BettingActions, GameStatus } from "@jeton/ts-sdk"; +import { mockPlayers } from "@src/lib/constants/mocks"; +import type { UIPlayer } from "../state/state"; + +export default function Pot({ players }: { players: (UIPlayer | null)[] }) { + const [started, setStarted] = useState(false); + const [winning, setWinning] = useState(false); + const [betting, setBetting] = useState(false); + const pot = useSelector(selectPot$()); + const gameStatus = useSelector(selectGameStatus$()); + const raisedSeats = players.reduce((acc, curr, i) => { + if ( + curr && + (curr.roundAction?.action === BettingActions.CALL || + curr.roundAction?.action === BettingActions.RAISE) + ) { + acc.push(i + 1); + } + return acc; + }, []); + + const winnerSeats = players?.reduce((acc, curr, i) => { + if (curr?.winAmount && curr.winAmount > 0) { + acc.push(i + 1); + } + return acc; + }, []); + + useEffect(() => { + if (winnerSeats && winnerSeats?.length > 0) { + setWinning(true); + setTimeout(() => { + setStarted(true); + }, 2000); + } else { + setWinning(false); + } + }, [winnerSeats]); + + useEffect(() => { + console.log(raisedSeats, winnerSeats); + }, [winnerSeats, raisedSeats]); + + const raise = useCallback(() => { + setBetting(true); + + setTimeout(() => { + setStarted(true); + + setTimeout(() => { + setBetting(false); + setStarted(false); + }); + }, 500); + }, []); + + useEffect(() => { + if ( + gameStatus === GameStatus.DrawRiver || + gameStatus === GameStatus.DrawFlop || + gameStatus === GameStatus.DrawTurn + ) { + if (raisedSeats.length > 0) { + raise(); + } else { + setWinning(false); + setStarted(false); + } + } + }, [gameStatus, raise, raisedSeats.length]); + + return ( + <> +
+ ${pot} +
+ {betting && + raisedSeats.map((seat) => ( + chips + ))} + + {winning && + winnerSeats.map((seat) => ( + chips + ))} + + ); +} diff --git a/apps/web/src/app/games/[id]/components/PrivateCards.tsx b/apps/web/src/app/games/[id]/components/PrivateCards.tsx new file mode 100644 index 0000000..b6d5420 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/PrivateCards.tsx @@ -0,0 +1,108 @@ +import { GameStatus } from "@jeton/ts-sdk"; +import { useSelector } from "@legendapp/state/react"; +import dealCardSound from "@src/assets/audio/effects/card-place.mp3"; +import { useAudio } from "@src/hooks/useAudio"; +import { CARDS_MAP } from "@src/lib/constants/cards"; +import { mockPrivateCards } from "@src/lib/constants/mocks"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { selectGameStatus$ } from "../state/selectors/gameSelectors"; +import Card from "./Card"; + +export default function PrivateCards({ + playersPrivateCards, +}: { + playersPrivateCards: Record | null; +}) { + const gameStatus = useSelector(selectGameStatus$()); + const [receivedCards, setReceivedCards] = useState(false); + const [revealedCards, setRevealedCards] = useState(false); + const [dealCards, setDealCards] = useState([]); + const dealCardEffect = useAudio(dealCardSound, "effect"); + + const cards = playersPrivateCards; + const mounted = useRef(false); + + const seats = useMemo(() => { + return cards ? Object.keys(cards).map((seat) => Number(seat)) : []; + }, [cards]); + + useEffect(() => { + if (mounted.current || seats.length === 0) return; + + mounted.current = true; + + seats.forEach((seat, index) => { + if (seat === 1) return; + + setTimeout(async () => { + await dealCardEffect.play(); + setDealCards((prev) => [...prev, seat]); + }, index * 200); + }); + }, [seats, dealCardEffect]); + + useEffect(() => { + if (gameStatus === GameStatus.ShowDown) { + setReceivedCards(true); + + setTimeout(() => { + setRevealedCards(true); + }, 600); + } + }); + + return ( + <> + {seats.map((seat, i) => { + if (seat === 1) return; + + return ( +
+ {revealedCards && cards ? ( + <> + {cards[seat]?.map( + (cardName, i) => + CARDS_MAP[cardName] && ( + + ), + )} + + ) : ( +
+
+
+
+ )} +
+ ); + })} + + ); +} diff --git a/apps/web/src/app/games/[id]/components/PublicCards.tsx b/apps/web/src/app/games/[id]/components/PublicCards.tsx new file mode 100644 index 0000000..55f0b86 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/PublicCards.tsx @@ -0,0 +1,26 @@ +import { useSelector } from "@legendapp/state/react"; +import { CARDS_MAP } from "@src/lib/constants/cards"; +import { mockPublicCards } from "@src/lib/constants/mocks"; +import { selectPublicCards$ } from "../state/selectors/gameSelectors"; +import Card from "./Card"; + +export default function PublicCards() { + const cards = useSelector(selectPublicCards$); + + return ( +
+ {cards.map((cardIndex) => { + const cardName = CARDS_MAP[cardIndex]; + return ( + cardName && ( + + ) + ); + })} +
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/Seat.tsx b/apps/web/src/app/games/[id]/components/Seat.tsx new file mode 100644 index 0000000..770a29a --- /dev/null +++ b/apps/web/src/app/games/[id]/components/Seat.tsx @@ -0,0 +1,196 @@ +import { GameStatus, PlayerStatus } from "@jeton/ts-sdk"; +import { useSelector } from "@legendapp/state/react"; +import Avatar1 from "@src/assets/images/avatars/avatar-1.png"; +import Avatar2 from "@src/assets/images/avatars/avatar-2.png"; +import Avatar3 from "@src/assets/images/avatars/avatar-3.png"; +import Avatar4 from "@src/assets/images/avatars/avatar-4.png"; +import Avatar5 from "@src/assets/images/avatars/avatar-5.png"; +import Avatar6 from "@src/assets/images/avatars/avatar-6.png"; +import Avatar7 from "@src/assets/images/avatars/avatar-7.png"; +import Avatar8 from "@src/assets/images/avatars/avatar-8.png"; +import Avatar9 from "@src/assets/images/avatars/avatar-9.png"; +import Avatar10 from "@src/assets/images/avatars/avatar-10.png"; +import WinnerStuff from "@src/assets/images/champagne-pixel-animated.gif"; +import Chip from "@src/assets/images/chips/chip.png"; +import Image, { type StaticImageData } from "next/image"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + selectAwaitingBetFrom$, + selectDealer$, + selectGamePlayers$, + selectGameStatus$, + selectShufflingPlayer$, +} from "../state/selectors/gameSelectors"; +import type { UIPlayer } from "../state/state"; +import DealerBadge from "./DealerBadge"; + +// Define the avatars array as a constant +const avatars = [ + Avatar1, + Avatar2, + Avatar3, + Avatar4, + Avatar5, + Avatar6, + Avatar7, + Avatar8, + Avatar9, + Avatar10, +]; + +// Function to assign unique avatars to players +function assignUniqueAvatars(players: (UIPlayer | null)[]): Record { + const availableAvatars = [...avatars]; + const assignedAvatars: Record = {}; + + players.forEach((player) => { + if (!player) return; + if (!assignedAvatars[player.id]) { + const avatarIndex = hashPlayerIDToAvatar(player.id, availableAvatars.length); + + const assignedAvatar = availableAvatars[avatarIndex]; + + assignedAvatars[player.id] = assignedAvatar ?? Avatar1; + + availableAvatars.splice(avatarIndex, 1); + } + }); + + return assignedAvatars; +} + +// Function to hash player IDs and assign avatar +function hashPlayerIDToAvatar(id: string, avatarCount: number): number { + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = id.charCodeAt(i) + ((hash << 5) - hash); + } + return Math.abs(hash % avatarCount); +} + +export default function Seat({ + player, + seatNumber, +}: { + player: UIPlayer; + seatNumber: number; +}) { + const mounted = useRef(false); + + // Selectors from the state + const players = useSelector(selectGamePlayers$()); + const shufflingPlayer = useSelector(selectShufflingPlayer$()); + const awaitingBetFrom = useSelector(selectAwaitingBetFrom$()); + const isPlayerTurn = awaitingBetFrom?.id === player.id; + const dealer = useSelector(selectDealer$()); + const gameStatus = useSelector(selectGameStatus$()); + + const [lastAction, setLastAction] = useState(""); + const isWinner = player.winAmount && player.winAmount > 0; + + // Use `useMemo` to assign avatars + const assignedAvatars = useMemo(() => (players ? assignUniqueAvatars(players) : {}), [players]); + const playerAvatar = assignedAvatars[player.id] ?? Avatar1; + + const isMainPlayerCards = useMemo(() => { + return seatNumber === 1; + }, [seatNumber]); + + useEffect(() => { + if (mounted.current) return; + mounted.current = true; + }, []); + + // Reset last action based on game status + useEffect(() => { + console.log("status: ", gameStatus); + + if ( + gameStatus === GameStatus.DrawRiver || + gameStatus === GameStatus.DrawFlop || + gameStatus === GameStatus.DrawTurn || + isPlayerTurn + ) { + setLastAction(""); + } + }, [gameStatus, isPlayerTurn]); + + // Update last action for the player + useEffect(() => { + if (player.roundAction) { + const { action, amount } = player.roundAction; + setLastAction(`${action} ${amount}`); + } + }, [player.roundAction]); + + return ( +
+
+ avatar +
+ {seatNumber === 1 ? "me" : player.id.slice(2, 8)} + + chip + {player.balance} + +
+
+ + {isWinner && ( + winner badge + )} + + {lastAction && ( + <> +
+

+ {player.status === PlayerStatus.folded ? "Folded" : lastAction} +

+
+
5 ? "right-0" : "left-0" + }`} + > +

+ {player.status === PlayerStatus.folded ? "Folded" : lastAction} +

+
+ + )} + + {dealer?.id === player.id && } +
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/ShufflingCards.tsx b/apps/web/src/app/games/[id]/components/ShufflingCards.tsx new file mode 100644 index 0000000..e1d95d5 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/ShufflingCards.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; + +const ShufflingCards = () => { + const totalCards = 32; + + // Calculate the angles for card positions + // biome-ignore lint/correctness/useExhaustiveDependencies: + const angles = useMemo(() => { + const step = 360 / totalCards; + return Array.from({ length: totalCards }, (_, index) => step * index); + }, [totalCards]); + + // Calculate the left positions for cards + // biome-ignore lint/correctness/useExhaustiveDependencies: + const positions = useMemo(() => { + let left = 0; + const margin = 0.2; + return Array.from({ length: totalCards }, (_, index) => { + const position = { left: left, zIndex: index }; + left += margin; + return position; + }); + }, [totalCards]); + + return ( +
+ {angles.map((angle, index) => ( +
+ ))} +
+ ); +}; + +export default ShufflingCards; diff --git a/apps/web/src/app/games/[id]/components/Table.tsx b/apps/web/src/app/games/[id]/components/Table.tsx index 7870026..de59668 100644 --- a/apps/web/src/app/games/[id]/components/Table.tsx +++ b/apps/web/src/app/games/[id]/components/Table.tsx @@ -1,57 +1,22 @@ -"use client"; - -import { useWallet } from "@aptos-labs/wallet-adapter-react"; -import FullPageLoading from "@jeton/ui/FullPageLoading"; -import { useSelector } from "@legendapp/state/react"; -import { useRouter } from "next/navigation"; -import { type FC, useEffect, useState } from "react"; -import { initGame, setTableId } from "../state/actions/gameActions"; -import { - selectGamePlayers$, - selectIsGameLoading$, -} from "../state/selectors/gameSelectors"; - -type TableComponentProps = { - id: string; -}; - -export const TableComponent: FC = ({ id }) => { - const [toffState, setToffState] = useState(false); - const players = useSelector(selectGamePlayers$()); - const router = useRouter(); - const { - connected, - isLoading: isWalletLoading, - signMessage, - signAndSubmitTransaction, - account, - } = useWallet(); - - useEffect(() => { - if (!isWalletLoading && !connected && toffState) { - router.push("/"); - } else if (!isWalletLoading && !connected) { - setTimeout(() => setToffState(true), 100); - } - }, [isWalletLoading, connected, router, toffState]); - - useEffect(() => { - if (!isWalletLoading && account) { - initGame(account.address, signMessage, signAndSubmitTransaction); - } - setTableId(id); - }, [id, signMessage, signAndSubmitTransaction, isWalletLoading, account]); - - const isLoading = useSelector(selectIsGameLoading$()) || isWalletLoading; - if (isLoading) return ; +import TableBackground from "@src/assets/images/table.png"; +import Image from "next/image"; +import type { ReactNode } from "react"; +export function Table({ children }: { children: ReactNode }) { return ( -
-

this is the actual game page

-

players are:

- {players?.map((p) => ( -

player id: {p.id}

- ))} +
+
+ table + + {children} +
); -}; +} diff --git a/apps/web/src/app/games/[id]/components/WaitingIndicator.tsx b/apps/web/src/app/games/[id]/components/WaitingIndicator.tsx new file mode 100644 index 0000000..017f959 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/WaitingIndicator.tsx @@ -0,0 +1,14 @@ +import Spinner from "@src/components/Spinner"; + +export default function WaitingIndicator() { + return ( +
+

+ Waiting for players :D +

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/games/[id]/components/useSubscribeToGameEvent.ts b/apps/web/src/app/games/[id]/components/useSubscribeToGameEvent.ts new file mode 100644 index 0000000..4713033 --- /dev/null +++ b/apps/web/src/app/games/[id]/components/useSubscribeToGameEvent.ts @@ -0,0 +1,25 @@ +import type { GameEventMap, GameEventTypes } from "@jeton/ts-sdk"; +import { useSelector } from "@legendapp/state/react"; +import { JetonContext } from "@src/components/JetonContextProvider"; +import { useContext, useEffect, useState } from "react"; +import { state$ } from "../state/state"; + +export const useSubscribeToGameEvent = (event: T) => { + const { game } = useContext(JetonContext); + const [eventState, setEventState] = useState(); + + useEffect(() => { + const listener = (...args: GameEventMap[T]) => { + setEventState(args); + }; + // biome-ignore lint/suspicious/noExplicitAny: + game?.addListener?.(event, listener as any); + + return () => { + // biome-ignore lint/suspicious/noExplicitAny: + game?.removeListener?.(event, listener as any); + }; + }, [game, event]); + + return eventState; +}; diff --git a/apps/web/src/app/games/[id]/page.tsx b/apps/web/src/app/games/[id]/page.tsx index 5c3be9e..d9ed98b 100644 --- a/apps/web/src/app/games/[id]/page.tsx +++ b/apps/web/src/app/games/[id]/page.tsx @@ -1,7 +1,144 @@ +"use client"; + export const runtime = "edge"; -import { TableComponent } from "./components/Table"; +import { useWallet } from "@aptos-labs/wallet-adapter-react"; +import { GameStatus } from "@jeton/ts-sdk"; +import { useSelector } from "@legendapp/state/react"; + +import InAppDialog from "@src/components/InAppDialog"; +import { JetonContext } from "@src/components/JetonContextProvider"; +import { mockPlayers } from "@src/lib/constants/mocks"; +import type { SignAndSubmitTransaction } from "@src/types/SignAndSubmitTransaction"; +import { askedInAppDialog, finalAddress } from "@src/utils/inAppWallet"; +import { orderPlayersSeats } from "@src/utils/seat"; +import { useRouter } from "next/navigation"; +import { useContext, useEffect, useMemo, useState } from "react"; +import React from "react"; +import DownloadModal from "./components/DownloadModal"; +import GameContainer from "./components/GameContainer"; +import GameStatusBox from "./components/GameStatusBox"; +import PlayerActions from "./components/PlayerActions"; +import Pot from "./components/Pot"; +import PrivateCards from "./components/PrivateCards"; +import PublicCards from "./components/PublicCards"; +import Seat from "./components/Seat"; +import ShufflingCards from "./components/ShufflingCards"; +import { Table } from "./components/Table"; +import WaitingIndicator from "./components/WaitingIndicator"; +import { initGame, setTableId } from "./state/actions/gameActions"; +import { + selectGamePlayers$, + selectGameStatus$, + selectMyCards$, + selectPublicCards$, + selectShufflingPlayer$, +} from "./state/selectors/gameSelectors"; + +export default function PlayPage({ params }: { params: { id: string } }) { + const { game, joinTable } = useContext(JetonContext); + const players = useSelector(selectGamePlayers$()); + const [toffState, setToffState] = useState(false); + const shufflingPlayer = useSelector(selectShufflingPlayer$()); + const gameStatus = useSelector(selectGameStatus$()); + const [drawPrivateCards, setDrawPrivateCards] = useState(false); + const [address, setInAppAddress] = useState(); + const myCards = useSelector(selectMyCards$()); + const router = useRouter(); + const [privateCard, setPrivateCards] = useState | null>(null); + const { connected, isLoading: isWalletLoading, signAndSubmitTransaction, account } = useWallet(); + + const reorderedPlayers = useMemo(() => { + const mainPlayer = players?.find((player) => player?.id === address); + return players && mainPlayer ? orderPlayersSeats(players, mainPlayer.id) : []; + }, [players, players?.length, address]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (gameStatus === GameStatus.DrawPrivateCards && !drawPrivateCards) { + setDrawPrivateCards(true); + } + }, [gameStatus]); + + useEffect(() => { + setInAppAddress(finalAddress(account?.address || "")); + }, [account]); + + useEffect(() => { + if (!isWalletLoading && account && joinTable && askedInAppDialog()) { + initGame( + account.address, + signAndSubmitTransaction as SignAndSubmitTransaction, + joinTable, + game, + ); + } + setTableId(params.id); + }, [params.id, signAndSubmitTransaction, isWalletLoading, account, joinTable, game]); + + useEffect(() => { + if (!isWalletLoading && !connected && toffState) { + console.log("here?"); + router.push("/"); + } else if (!isWalletLoading && !connected) { + setTimeout(() => setToffState(true), 100); + } + }, [isWalletLoading, connected, router, toffState]); -export default function TablePage({ params }: { params: { id: string } }) { - return ; + useEffect(() => { + if (myCards && myCards.length > 0) { + const privateCards = reorderedPlayers.reduce( + (acc, player, seat) => { + if (player) { + acc[seat + 1] = player.cards ?? []; + } + return acc; + }, + {} as Record, + ); + + setPrivateCards(privateCards); + } + }, [reorderedPlayers, myCards]); + + return ( + + + {reorderedPlayers.map( + (player, i) => player && , + )} + {shufflingPlayer?.id && } +
+ + {gameStatus === GameStatus.AwaitingStart && } +
+ + {drawPrivateCards && players && ( + <> + + + + )} +
+ + {/* */} + + {connected && } +
+ ); } + +// mock shuffling for testing +// const [dealerSeat, setDealerSeat] = useState(1); + +// useEffect(() => { +// const timeout = setTimeout(() => { +// const dealerInterval = setInterval(() => { +// setDealerSeat((prevSeat) => (prevSeat < 9 ? prevSeat + 1 : 1)); +// }, 3000); + +// return () => clearInterval(dealerInterval); +// }, 2000); + +// return () => clearTimeout(timeout); +// }, []); diff --git a/apps/web/src/app/games/[id]/state/actions/gameActions.ts b/apps/web/src/app/games/[id]/state/actions/gameActions.ts index df6f181..dc596c6 100644 --- a/apps/web/src/app/games/[id]/state/actions/gameActions.ts +++ b/apps/web/src/app/games/[id]/state/actions/gameActions.ts @@ -1,52 +1,86 @@ -import type { - InputTransactionData, - SignMessagePayload, - SignMessageResponse, -} from "@aptos-labs/wallet-adapter-react"; -import { GameEventTypes, createGame, getTableInfo } from "@jeton/ts-sdk"; +"use client"; +import { GameEventTypes, type Jeton } from "@jeton/ts-sdk"; import { when } from "@legendapp/state"; import { state$ } from "../state"; -import { newPlayerCheckedInHandler } from "./gameEventHandlers"; +import { + awaitingPlayerBetHandler, + handStartedHandler, + newPlayerCheckedInHandler, + playerPlacedBetHandler, + playerShufflingHandler, + privateCardsDecryptionHandler, + receivedPrivateCardHandler, + receivedPublicCardsHandler, + receivedShowDown, +} from "./gameEventHandlers"; + +import { decryptCardShareZkey, shuffleEncryptDeckZkey } from "@jeton/zk-deck"; +//@ts-ignore +import decryptCardShareWasm from "@jeton/zk-deck/wasm/decrypt-card-share.wasm"; +//@ts-ignore +import shuffleEncryptDeckWasm from "@jeton/zk-deck/wasm/shuffle-encrypt-deck.wasm"; +import type { SignAndSubmitTransaction } from "@src/types/SignAndSubmitTransaction"; +import { finalAddressAndSignFunction } from "@src/utils/inAppWallet"; export const initGame = async ( address: string, - signMessage: (message: SignMessagePayload) => Promise, - signAndSubmitTransaction: ( - transaction: InputTransactionData, - ) => Promise, + signAndSubmitTransaction: SignAndSubmitTransaction, + joinTable: (typeof Jeton)["joinTable"], + game?: Jeton, ) => { await when( - () => - state$.tableId.get() !== undefined && - state$.loading.get() && - !state$.initializing.get(), + () => state$.tableId.get() !== undefined && state$.loading.get() && !state$.initializing.get(), ); + if (state$.initializing.get()) { + return; + } + console.log("init game", game); state$.initializing.set(true); const tableId = state$.tableId.peek() as string; - const tableInfo = await getTableInfo(tableId); - const game = createGame({ + // TODO: should we get 'entryGameState' from user? + const [finalAddress, finalSignAndSubmit] = finalAddressAndSignFunction( address, - tableInfo, - signMessage, signAndSubmitTransaction, - }); - state$.game.set(game); - setGameEventListeners(); - const entryGameState = await game.checkIn(1000); - state$.gameState.set(entryGameState); + ); + + const finalGame = + game || + (await joinTable(tableId, 1000, finalAddress, finalSignAndSubmit, { + decryptCardShareWasm, + shuffleEncryptDeckWasm, + decryptCardShareZkey, + shuffleEncryptDeckZkey, + })); + setGameEventListeners(finalGame); + const entryGameState = finalGame.gameState; + if (!entryGameState) throw Error("should have existed"); + state$.gameState.players.set(entryGameState.players.map((p) => p)); + state$.gameState.status.set(entryGameState.status); + state$.gameState.dealer.set(entryGameState.players[entryGameState.dealerIndex]!); + if (entryGameState.shufflingPlayer) { + state$.gameState.shufflingPlayer.set(entryGameState.shufflingPlayer); + } state$.loading.set(false); - state$.initializing.set(false); + //state$.initializing.set(false); }; -function setGameEventListeners() { - const game = state$.game.peek(); - if (game === undefined) throw new Error("game must exist"); - // TODO - game.addListener?.( - GameEventTypes.newPlayerCheckedIn, - newPlayerCheckedInHandler, - ); + +function setGameEventListeners(game: Jeton) { + game.addListener?.(GameEventTypes.NEW_PLAYER_CHECK_IN, newPlayerCheckedInHandler); + game.addListener?.(GameEventTypes.HAND_STARTED, handStartedHandler); + game.addListener?.(GameEventTypes.PLAYER_SHUFFLING, playerShufflingHandler); + game.addListener?.(GameEventTypes.PRIVATE_CARD_DECRYPTION_STARTED, privateCardsDecryptionHandler); + game.addListener?.(GameEventTypes.RECEIVED_PRIVATE_CARDS, receivedPrivateCardHandler); + game.addListener?.(GameEventTypes.AWAITING_BET, awaitingPlayerBetHandler); + game.addListener?.(GameEventTypes.PLAYER_PLACED_BET, playerPlacedBetHandler); + game.addListener?.(GameEventTypes.RECEIVED_PUBLIC_CARDS, receivedPublicCardsHandler); + game.addListener?.(GameEventTypes.SHOW_DOWN, receivedShowDown); + console.log("set game event listeners end"); } export const setTableId = (id: string) => { state$.tableId.set(id); }; + +export const setProgress = (percentage: number) => { + state$.downloadingAssets.loadingProgress.set(percentage); +}; diff --git a/apps/web/src/app/games/[id]/state/actions/gameEventHandlers.ts b/apps/web/src/app/games/[id]/state/actions/gameEventHandlers.ts index 5e6c1b7..e03313f 100644 --- a/apps/web/src/app/games/[id]/state/actions/gameEventHandlers.ts +++ b/apps/web/src/app/games/[id]/state/actions/gameEventHandlers.ts @@ -1,8 +1,129 @@ -import type { Player } from "@jeton/ts-sdk"; +import { + type AwaitingBetEvent, + GameStatus, + type HandStartedEvent, + type Player, + type PlayerPlacedBetEvent, + type ReceivedPrivateCardsEvent, + getGameStatus, + type playerShufflingEvent, +} from "@jeton/ts-sdk"; +import type { ShowDownEvent } from "@jeton/ts-sdk"; +import type { ReceivedPublicCardsEvent } from "@jeton/ts-sdk"; +import { PublicCardRounds } from "@jeton/ts-sdk"; import { state$ } from "../state"; export function newPlayerCheckedInHandler(player: Player) { const gameState$ = state$.gameState; if (!gameState$.get()) throw new Error("game must exist in state"); gameState$.players.push(player); + console.log("players: ", gameState$.players.peek(), "new player: ", player); +} + +export function handStartedHandler({ dealer }: HandStartedEvent) { + console.log("hand started handler", dealer); + const gameState$ = state$.gameState; + gameState$.dealer.set(dealer); + gameState$.status.set(GameStatus.Shuffle); + gameState$.players.forEach((player) => { + player.cards.set(undefined); + player.bet.set(0); + player.winAmount.set(undefined); + player.roundAction.set(undefined); + }); + gameState$.myCards.set(undefined); + gameState$.flopCards.set(undefined); + gameState$.riverCard.set(undefined); + gameState$.turnCard.set(undefined); + gameState$.pot.set(0); + gameState$.betState.set(undefined); +} + +export function playerShufflingHandler(player: playerShufflingEvent) { + console.log("player shuffling handler"); + state$.gameState.shufflingPlayer.set(player); + state$.gameState.status.set(GameStatus.Shuffle); +} + +export function privateCardsDecryptionHandler() { + state$.gameState.status.set(GameStatus.DrawPrivateCards); + state$.gameState.shufflingPlayer.set(undefined); +} + +export function receivedPrivateCardHandler({ cards }: ReceivedPrivateCardsEvent) { + state$.gameState.status.set(GameStatus.BetPreFlop); + state$.gameState.myCards.set(cards); +} + +export function awaitingPlayerBetHandler({ + bettingRound, + pot, + bettingPlayer, + availableActions, +}: AwaitingBetEvent) { + console.log("awaiting bet", bettingRound, pot, bettingPlayer, availableActions); + state$.gameState.status.set(getGameStatus(bettingRound)); + state$.gameState.pot.set(pot); + if (!state$.gameState.betState.peek()) { + state$.gameState.betState.set({ round: bettingRound, availableActions }); + } + state$.gameState.betState.awaitingBetFrom.set(bettingPlayer); + state$.gameState.betState.availableActions.set(availableActions); +} + +export function playerPlacedBetHandler({ + bettingRound, + player, + potAfterBet, + betAction, + availableActions, +}: PlayerPlacedBetEvent) { + console.log("placed bet", bettingRound, potAfterBet, betAction, availableActions); + state$.gameState.status.set(getGameStatus(bettingRound)); + state$.gameState.pot.set(potAfterBet); + if (!state$.gameState.betState.peek()) { + state$.gameState.betState.set({ round: bettingRound, availableActions }); + } + if (state$.gameState.betState.awaitingBetFrom.peek() === player) { + state$.gameState.betState.awaitingBetFrom.set(undefined); + } + const player$ = state$.gameState.players.find((p) => p.id.peek() === player.id)!; + player$.roundAction.action.set(betAction); + player$.balance.set(player.balance); + player$.bet.set(player.bet); + player$.status.set(player.status); + //TODO: calc amount + player$.roundAction.amount.set(10); + state$.gameState.betState.availableActions.set(availableActions); +} + +export function clearPlayersRoundAction() { + state$.gameState.players.forEach((player) => { + player.roundAction.set(undefined); + }); +} + +export function receivedPublicCardsHandler({ cards, round }: ReceivedPublicCardsEvent) { + console.log("received public cards", round, cards); + clearPlayersRoundAction(); + if (round === PublicCardRounds.FLOP) { + state$.gameState.flopCards.set(cards); + } else if (round === PublicCardRounds.RIVER) { + state$.gameState.riverCard.set(cards); + } else if (round === PublicCardRounds.TURN) { + state$.gameState.turnCard.set(cards); + } +} + +export function receivedShowDown(data: ShowDownEvent) { + console.log("received showdown event", data); + state$.gameState.players.forEach((player) => { + const eventPlayer = data[player.id.get()!]; + if (eventPlayer) { + player.cards.set(eventPlayer.cards); + player.balance.set(eventPlayer.player.balance); + player.bet.set(0); + player.winAmount.set(eventPlayer.winAmount); + } + }); } diff --git a/apps/web/src/app/games/[id]/state/selectors/gameSelectors.ts b/apps/web/src/app/games/[id]/state/selectors/gameSelectors.ts index cd9521e..d852f56 100644 --- a/apps/web/src/app/games/[id]/state/selectors/gameSelectors.ts +++ b/apps/web/src/app/games/[id]/state/selectors/gameSelectors.ts @@ -1,9 +1,20 @@ -import type { Player } from "@jeton/ts-sdk"; -import type { Observable, ObservableBoolean } from "@legendapp/state"; import { state$ } from "../state"; -export const selectIsGameLoading$: () => ObservableBoolean = () => - state$.loading; +export const selectIsGameLoading$ = () => state$.loading; +export const selectGamePlayers$ = () => state$.gameState.players; +export const selectGameStatus$ = () => state$.gameState.status; +export const selectShufflingPlayer$ = () => state$.gameState.shufflingPlayer; +export const selectDealer$ = () => state$.gameState.dealer; +export const selectMyCards$ = () => state$.gameState.myCards; +export const selectPot$ = () => state$.gameState.pot; +export const selectBetState$ = () => state$.gameState.betState; +export const selectAvailableActions$ = () => state$.gameState.betState?.availableActions ?? []; +export const selectAwaitingBetFrom$ = () => state$.gameState.betState?.awaitingBetFrom; +export const selectPublicCards$ = () => { + const flopCards = state$.gameState.flopCards.get() || []; + const turnCard = state$.gameState.turnCard.get() || []; + const riverCard = state$.gameState.riverCard.get() || []; -export const selectGamePlayers$: () => Observable = () => - state$.gameState.players; + return [flopCards, turnCard, riverCard].flat(); +}; +export const selectProgressPercentage$ = () => state$.downloadingAssets.loadingProgress; diff --git a/apps/web/src/app/games/[id]/state/state.ts b/apps/web/src/app/games/[id]/state/state.ts index f315373..d333c58 100644 --- a/apps/web/src/app/games/[id]/state/state.ts +++ b/apps/web/src/app/games/[id]/state/state.ts @@ -1,15 +1,55 @@ -import type { Game, GameState } from "@jeton/ts-sdk"; +import { + type BettingActions, + type BettingRounds, + GameStatus, + type Jeton, + type PlacingBettingActions, + type Player, +} from "@jeton/ts-sdk"; import { type Observable, observable } from "@legendapp/state"; -interface State { +export type UIPlayer = Player & { + roundAction?: { + action: BettingActions; + amount: number; + }; + cards?: number[]; + winAmount?: number; +}; + +type GameState = { + dealer?: UIPlayer; + players: (UIPlayer | null)[]; + status?: GameStatus; + shufflingPlayer?: UIPlayer; + myCards?: [number, number]; + flopCards?: [number, number, number]; + turnCard?: [number]; + riverCard?: [number]; + pot: number; + betState?: { + round: BettingRounds; + awaitingBetFrom?: UIPlayer; + availableActions: PlacingBettingActions[]; + }; +}; +export interface State { tableId?: string; loading: boolean; + downloadingAssets?: { + loadingProgress: number; + }; initializing: boolean; - game?: Game; gameState?: GameState; } export const state$: Observable = observable({ loading: true, initializing: false, + gameState: { + pot: 0, + players: [], + status: GameStatus.AwaitingStart, + dealer: {} as Player, + }, }); diff --git a/apps/web/src/app/games/page.tsx b/apps/web/src/app/games/page.tsx old mode 100644 new mode 100755 index 5358002..733ef6d --- a/apps/web/src/app/games/page.tsx +++ b/apps/web/src/app/games/page.tsx @@ -1,19 +1,129 @@ -import { getTablesInfo } from "@jeton/ts-sdk"; -import { LaunchGameButton } from "@src/components/LaunchGameButton"; +"use client"; + +import { WalletSelector } from "@aptos-labs/wallet-adapter-ant-design"; +import { useWallet } from "@aptos-labs/wallet-adapter-react"; +import { createZkDeck } from "@jeton/ts-sdk"; +import buttonBackground from "@src/assets/images/button.png"; +import Image from "next/image"; +import Link from "next/link"; +import React, { useEffect, useRef } from "react"; +import { useState } from "react"; +import DownloadModal from "./[id]/components/DownloadModal"; + +import { decryptCardShareZkey, shuffleEncryptDeckZkey } from "@jeton/zk-deck"; +//@ts-ignore +import decryptCardShareWasm from "@jeton/zk-deck/wasm/decrypt-card-share.wasm"; +//@ts-ignore +import shuffleEncryptDeckWasm from "@jeton/zk-deck/wasm/shuffle-encrypt-deck.wasm"; +import InAppDialog from "@src/components/InAppDialog"; +import { setProgress } from "./[id]/state/actions/gameActions"; + +export const runtime = "edge"; + +export default function Home() { + const { connected, disconnect, isLoading } = useWallet(); + const [openModal, setOpenModal] = useState(false); + const [downloadingAssets, setDownloadingAssets] = useState(true); + const startedDownloadingRef = useRef(false); + + useEffect(() => { + if (!startedDownloadingRef.current) { + startedDownloadingRef.current = true; + createZkDeck( + { + decryptCardShareWasm, + shuffleEncryptDeckWasm, + decryptCardShareZkey, + shuffleEncryptDeckZkey, + }, + ({ percentage }) => { + setProgress(percentage); + }, + ).then(() => { + setDownloadingAssets(false); + }); + } + }, []); + + const options = [ + { + label: "Create game", + url: "/create", + }, + { + label: "Join game", + url: "/join", + }, + { + label: "Jeton homepage", + url: "/", + }, + ]; + + if (downloadingAssets) return ; -// I used button here to show case client components -// otherwise it makes more sense to use Link -export default async function GamePage() { - const tables = await getTablesInfo(); return ( -
- this is game page - {tables.map((table) => ( -
- this is table {table.id} - +
+ Logo +
+
+ {connected ? ( + <> + {options.map((btn) => ( + + {btn.label} + + ))} + + + ) : ( + <> +

+ Please connect your wallet to create or join a game. +

+
+ +
+ + + + )}
- ))} +
+ {connected && }
); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index bd6213e..dae0108 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,3 +1,263 @@ +@import "nes.css/css/nes.css"; + @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +.seat-position, +.chips, +.cards { + position: absolute; + will-change: transform; +} + +@media (min-width: 768px) { + .seat-1 { + top: 80% !important; + left: 50% !important; + transform: translateX(-50%); + } + + .seat-2 { + top: 90% !important; + left: 22% !important; + transform: translateY(-50%); + } + + .seat-3 { + top: 70% !important; + left: 4% !important; + transform: translateY(-50%); + } + + .seat-4 { + top: 25% !important; + left: 4% !important; + transform: translateY(-50%); + } + + .seat-5 { + top: 10% !important; + left: 22% !important; + transform: translateY(-50%); + } + + .seat-6 { + top: 10% !important; + left: 70% !important; + transform: translateY(-50%); + } + + .seat-7 { + top: 25% !important; + left: 87% !important; + transform: translateY(-50%); + } + + .seat-8 { + top: 70% !important; + left: 87% !important; + transform: translateY(-50%); + } + + .seat-9 { + top: 90% !important; + left: 75% !important; + transform: translateY(-50%); + } + + /* Dealer */ + .seat-dealer { + top: -5% !important; + left: 50% !important; + transform: translateX(-50%); + } + + .cards-center { + top: 50% !important; + left: 50% !important; + } + + .cards-2 { + top: 68% !important; + left: 33% !important; + transform: translateX(-50%); + } + + .cards-3 { + top: 55% !important; + left: 15% !important; + transform: translateX(-50%); + } + + .cards-4 { + top: 35% !important; + left: 15% !important; + transform: translateX(-50%); + } + + .cards-5 { + top: 20% !important; + left: 33% !important; + transform: translateX(-50%); + } + + .cards-6 { + top: 20% !important; + left: 67% !important; + transform: translateX(-50%); + } + + .cards-7 { + top: 35% !important; + left: 82% !important; + transform: translateX(-50%); + } + + .cards-8 { + top: 60% !important; + left: 82% !important; + transform: translateX(-50%); + } + + .cards-9 { + top: 68% !important; + left: 70% !important; + transform: translateX(-50%); + } + + .pot { + top: 65% !important; + left: 50% !important; + transform: translateX(-50%); + } +} + +@media (max-width: 768px) { + .cards-center { + top: 50% !important; + left: 50% !important; + } + + .pot { + top: 75%; + left: 50%; + transform: translateX(-50%); + } + + .seat-1 { + top: 105%; + left: 50%; + transform: translateX(-50%); + } + + .seat-2 { + top: 100%; + left: 24%; + transform: translateY(-50%); + } + + .seat-3 { + top: 70%; + left: 24%; + transform: translateY(-50%); + } + + .seat-4 { + top: 28%; + left: 24%; + transform: translateY(-50%); + } + + .seat-5 { + top: -10%; + left: 25%; + transform: translateY(-50%); + } + + .seat-6 { + top: -10%; + left: 64%; + transform: translateY(-50%); + } + + .seat-7 { + top: 28%; + left: 67%; + transform: translateY(-50%); + } + + .seat-8 { + top: 70%; + left: 67%; + transform: translateY(-50%); + } + + .seat-9 { + top: 100%; + left: 67%; + transform: translateY(-50%); + } + + .seat-dealer { + top: -5% !important; + left: 45% !important; + transform: translate(0); + } + + .cards-2 { + top: 89%; + left: 40%; + transform: translateX(-50%); + } + + .cards-3 { + top: 63%; + left: 40%; + transform: translateX(-50%); + } + + .cards-4 { + top: 20%; + left: 40%; + transform: translateX(-50%); + } + + .cards-5 { + top: 0%; + left: 40%; + transform: translateX(-50%); + } + + .cards-6 { + top: 0%; + left: 60%; + transform: translateX(-50%); + } + + .cards-7 { + top: 20%; + left: 60%; + transform: translateX(-50%); + } + + .cards-8 { + top: 63%; + left: 60%; + transform: translateX(-50%); + } + + .cards-9 { + top: 89%; + left: 60%; + transform: translateX(-50%); + } +} + +.rotate-y-180 { + transform: rotateY(180deg); +} + +.rotate-y-0 { + transform: rotateY(0deg); +} diff --git a/apps/web/src/app/join/page.tsx b/apps/web/src/app/join/page.tsx new file mode 100644 index 0000000..48ebac6 --- /dev/null +++ b/apps/web/src/app/join/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function CreatePage() { + const router = useRouter(); + + useEffect(() => { + router.push("/games"); + }, [router]); + return null; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx old mode 100644 new mode 100755 index 70dcaf6..bde7998 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,16 +1,33 @@ -import type { Metadata } from "next"; +"use client"; + +import background from "@src/assets/images/main-menu-background.png"; +import { Press_Start_2P } from "next/font/google"; +import Image from "next/image"; +import { useSelectedLayoutSegments } from "next/navigation"; +import Script from "next/script"; import "./globals.css"; import "@aptos-labs/wallet-adapter-ant-design/dist/index.css"; import "@jeton/ui/styles.css"; +import { JetonProvider } from "@src/components/JetonContextProvider"; +import { LayoutTransition } from "@src/components/LayoutTransition"; +import SoundSettings from "@src/components/SoundSettings"; import { WalletProvider } from "@src/components/WalletProvider"; +import { useButtonClickSound } from "@src/hooks/useButtonClickSound"; + +const pressStart2P = Press_Start_2P({ + weight: "400", + style: "normal", + display: "swap", + subsets: ["latin"], +}); -export const metadata: Metadata = { - title: "Jeton DAO", - description: - "Decentralized poker protocol enabling trustless, transparent, and community-governed gameplay", -}; +// export const metadata: Metadata = { +// title: "Jeton DAO", +// description: +// "Decentralized poker protocol enabling trustless, transparent, and community-governed gameplay", +// }; export default function RootLayout({ children, @@ -19,13 +36,37 @@ export default function RootLayout({ children: React.ReactNode; modal: React.ReactNode; }>) { + useButtonClickSound(); + const segments = useSelectedLayoutSegments(); + return ( - + -
{children}
-
{modal}
+ + + {children} + + background + + + {!(segments[0] === "games" && segments[1]?.startsWith("0x")) && modal} +
+