diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..679a4a4 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,126 @@ +# Deployment Guide + +This guide covers deploying: + +1. Smart contracts to Monad testnet (`monadTestnet`) +2. Next.js frontend to Vercel + +## 1. Prerequisites + +- Node `>=20.18.3` +- Yarn `3.2.3` (via Corepack) +- A funded Monad testnet wallet for contract deployment +- Optional but recommended: + - `NEXT_PUBLIC_ALCHEMY_API_KEY` + - `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` + +From repo root: + +```bash +yarn install +``` + +## 2. Configure Environment Variables + +### Hardhat env (`packages/hardhat/.env`) + +```bash +cp packages/hardhat/.env.example packages/hardhat/.env +``` + +Set API keys in `packages/hardhat/.env`: + +```bash +ALCHEMY_API_KEY=... +ETHERSCAN_MAINNET_API_KEY=... +``` + +Create or import deployer key (writes `DEPLOYER_PRIVATE_KEY_ENCRYPTED`): + +```bash +yarn account:generate +# or +yarn account:import +``` + +### Next.js env (`packages/nextjs/.env.local`) + +```bash +cp packages/nextjs/.env.example packages/nextjs/.env.local +``` + +Set: + +```bash +NEXT_PUBLIC_ALCHEMY_API_KEY=... +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=... +``` + +## 3. Deploy Contracts to Monad Testnet + +Check deployer wallet and balance: + +```bash +yarn account +``` + +Deploy: + +```bash +yarn deploy --network monadTestnet +``` + +Notes: + +- You will be prompted for the password used to encrypt `DEPLOYER_PRIVATE_KEY_ENCRYPTED`. +- Deployment artifacts are written to `packages/hardhat/deployments/monadTestnet`. +- The frontend contract map is auto-generated at `packages/nextjs/contracts/deployedContracts.ts`. + +## 4. Verify Contracts + +Recommended command: + +```bash +yarn hardhat:hardhat-verify --network monadTestnet +``` + +You can also use: + +```bash +yarn verify +``` + +This runs `packages/hardhat/scripts/verifyContract.ts` and attempts verification from the latest deployment artifact. + +## 5. Deploy Frontend to Vercel + +From repo root: + +```bash +yarn vercel:login +yarn vercel +``` + +For production deployment: + +```bash +yarn vercel --prod +``` + +Set these Vercel project env vars before production deploy: + +- `NEXT_PUBLIC_ALCHEMY_API_KEY` +- `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` +- `GAME_ENGINE_URL` (optional; only if you run an external game engine API) + +## 6. Post-Deploy Checklist + +1. Open deployed app URL and connect wallet. +2. Switch to Monad testnet in wallet. +3. Confirm contracts appear and reads succeed. +4. If tournament feed is needed, ensure `GAME_ENGINE_URL` points to a reachable API. + +## 7. Important Caveat (Game Engine) + +`yarn run-game` currently runs on `localhost` and reads `packages/hardhat/deployments/localhost`. +For a production game-engine service, you will need to adapt `packages/hardhat/scripts/runGame.ts` and deployment wiring for non-local networks. diff --git a/README.md b/README.md index 108f747..bbe02dd 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,10 @@ Visit our [docs](https://docs.scaffoldeth.io) to learn how to start building wit To know more about its features, check out our [website](https://scaffoldeth.io). +For deployment steps in this repo, see [DEPLOYMENT.md](./DEPLOYMENT.md). + ## Contributing to Scaffold-ETH 2 We welcome contributions to Scaffold-ETH 2! -Please see [CONTRIBUTING.MD](https://github.com/scaffold-eth/scaffold-eth-2/blob/main/CONTRIBUTING.md) for more information and guidelines for contributing to Scaffold-ETH 2. \ No newline at end of file +Please see [CONTRIBUTING.MD](https://github.com/scaffold-eth/scaffold-eth-2/blob/main/CONTRIBUTING.md) for more information and guidelines for contributing to Scaffold-ETH 2. diff --git a/packages/nextjs/app/tournaments/[id]/page.tsx b/packages/nextjs/app/tournaments/[id]/page.tsx index 49588c6..3d68d65 100644 --- a/packages/nextjs/app/tournaments/[id]/page.tsx +++ b/packages/nextjs/app/tournaments/[id]/page.tsx @@ -2,7 +2,7 @@ import { use, useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { formatEther } from "viem"; import { useAccount } from "wagmi"; @@ -10,6 +10,8 @@ import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { BettingPanel } from "~~/components/poker/BettingPanel"; import { EnterAgentModal } from "~~/components/poker/EnterAgentModal"; import { GameFeed } from "~~/components/poker/GameFeed"; +import { LastTournamentResultsModal } from "~~/components/poker/LastTournamentResultsModal"; +import type { LastTournamentResult } from "~~/components/poker/LastTournamentResultsModal"; import { PokerTable } from "~~/components/poker/PokerTable"; import { TournamentStatusBadge } from "~~/components/poker/TournamentStatusBadge"; import { @@ -18,6 +20,7 @@ import { useScaffoldWriteContract, } from "~~/hooks/scaffold-eth"; import { useGameFeed } from "~~/hooks/useGameFeed"; +import type { GameEvent } from "~~/hooks/useGameFeed"; import { useTournament } from "~~/hooks/useTournaments"; function toChipNumber(value: unknown): number { @@ -30,18 +33,63 @@ function toChipNumber(value: unknown): number { return 0; } +function toOptionalNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function parseResultFromEvents(tournamentId: number, eventList: GameEvent[]): LastTournamentResult | null { + const winnerEvent = [...eventList].reverse().find(event => event.type === "winner"); + if (!winnerEvent) return null; + + let winnerName = "Unknown winner"; + let winnerSeat: number | null = null; + let totalPot: number | null = null; + + try { + const parsed = JSON.parse(winnerEvent.data) as Record; + if (typeof parsed.name === "string" && parsed.name.trim()) winnerName = parsed.name.trim(); + winnerSeat = toOptionalNumber(parsed.seat); + totalPot = toOptionalNumber(parsed.totalPot); + } catch { + // Ignore malformed winner payloads + } + + const lastHandStartEvent = [...eventList].reverse().find(event => event.type === "hand_start"); + let handsPlayed: number | null = null; + if (lastHandStartEvent) { + try { + const parsed = JSON.parse(lastHandStartEvent.data) as Record; + handsPlayed = toOptionalNumber(parsed.hand); + } catch { + // Ignore malformed hand_start payloads + } + } + + return { tournamentId, winnerName, winnerSeat, totalPot, handsPlayed }; +} + export default function TournamentDetail({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const router = useRouter(); + const searchParams = useSearchParams(); const tournamentId = BigInt(id); const { tournament, agents, refetch } = useTournament(tournamentId); const { events, isLoading: feedLoading } = useGameFeed(id); const { address: connectedAddress } = useAccount(); const [showEnterModal, setShowEnterModal] = useState(false); + const [showLastTournamentResultModal, setShowLastTournamentResultModal] = useState(false); + const [lastTournamentResult, setLastTournamentResult] = useState(null); const [autoStarting, setAutoStarting] = useState(false); const notifiedEventCountRef = useRef(null); const autoFollowRef = useRef(false); + const handledRedirectRef = useRef(null); const { data: operatorAddress } = useScaffoldReadContract({ contractName: "PokerVault", @@ -121,12 +169,85 @@ export default function TournamentDetail({ params }: { params: Promise<{ id: str const currentId = Number(id); if (latestTournamentId <= currentId) return; + const query = new URLSearchParams({ fromEndedTournament: String(currentId) }); + const previousTournamentResult = parseResultFromEvents(currentId, events); + if (previousTournamentResult) { + query.set("winnerName", previousTournamentResult.winnerName); + if (previousTournamentResult.winnerSeat !== null) { + query.set("winnerSeat", String(previousTournamentResult.winnerSeat)); + } + if (previousTournamentResult.totalPot !== null) { + query.set("winnerPot", String(previousTournamentResult.totalPot)); + } + if (previousTournamentResult.handsPlayed !== null) { + query.set("handsPlayed", String(previousTournamentResult.handsPlayed)); + } + } + autoFollowRef.current = true; toast("Next tournament is live", { description: `Moving to Tournament #${latestTournamentId}`, }); - router.push(`/tournaments/${latestTournamentId}`); - }, [id, isFinished, nextTournamentId, router]); + router.push(`/tournaments/${latestTournamentId}?${query.toString()}`); + }, [events, id, isFinished, nextTournamentId, router]); + + useEffect(() => { + const redirectedFrom = searchParams.get("fromEndedTournament"); + if (!redirectedFrom) return; + + const key = `${id}:${redirectedFrom}`; + if (handledRedirectRef.current === key) return; + handledRedirectRef.current = key; + + const redirectedFromId = Number(redirectedFrom); + const currentId = Number(id); + const clearRedirectQuery = () => router.replace(`/tournaments/${id}`); + + if (!Number.isInteger(redirectedFromId) || redirectedFromId <= 0 || redirectedFromId >= currentId) { + clearRedirectQuery(); + return; + } + + const winnerName = searchParams.get("winnerName")?.trim() || ""; + const winnerSeat = toOptionalNumber(searchParams.get("winnerSeat")); + const winnerPot = toOptionalNumber(searchParams.get("winnerPot")); + const handsPlayed = toOptionalNumber(searchParams.get("handsPlayed")); + + const hasQueryResult = winnerName.length > 0 || winnerSeat !== null || winnerPot !== null || handsPlayed !== null; + + if (hasQueryResult) { + setLastTournamentResult({ + tournamentId: redirectedFromId, + winnerName: winnerName || (winnerSeat !== null ? `Seat ${winnerSeat}` : "Unknown winner"), + winnerSeat, + totalPot: winnerPot, + handsPlayed, + }); + setShowLastTournamentResultModal(true); + clearRedirectQuery(); + return; + } + + let cancelled = false; + fetch(`/api/game/${redirectedFromId}`) + .then(response => (response.ok ? response.json() : { events: [] })) + .then(json => { + if (cancelled) return; + const parsedResult = parseResultFromEvents(redirectedFromId, (json?.events as GameEvent[] | undefined) ?? []); + if (parsedResult) { + setLastTournamentResult(parsedResult); + setShowLastTournamentResultModal(true); + } + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) clearRedirectQuery(); + }); + + return () => { + cancelled = true; + }; + }, [id, router, searchParams]); useEffect(() => { if (events.length === 0) return; @@ -462,6 +583,13 @@ export default function TournamentDetail({ params }: { params: Promise<{ id: str onClose={() => setShowEnterModal(false)} /> )} + {lastTournamentResult && ( + + )} ); } diff --git a/packages/nextjs/components/poker/LastTournamentResultsModal.tsx b/packages/nextjs/components/poker/LastTournamentResultsModal.tsx new file mode 100644 index 0000000..5c5f51e --- /dev/null +++ b/packages/nextjs/components/poker/LastTournamentResultsModal.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~~/components/ui/alert-dialog"; + +export type LastTournamentResult = { + tournamentId: number; + winnerName: string; + winnerSeat: number | null; + totalPot: number | null; + handsPlayed: number | null; +}; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + result: LastTournamentResult; +}; + +export function LastTournamentResultsModal({ open, onOpenChange, result }: Props) { + return ( + + + + Last Tournament Finished + + Tournament #{result.tournamentId} ended. You were moved to the newest live table. + + + +
+
+ Winner + {result.winnerName} +
+ {result.winnerSeat !== null && ( +
+ Seat + {result.winnerSeat} +
+ )} + {result.handsPlayed !== null && ( +
+ Hands played + {result.handsPlayed} +
+ )} + {result.totalPot !== null && ( +
+ Winning stack + {result.totalPot.toLocaleString()} +
+ )} +
+ + + onOpenChange(false)}>Continue + +
+
+ ); +} diff --git a/packages/nextjs/components/ui/alert-dialog.tsx b/packages/nextjs/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..55293d5 --- /dev/null +++ b/packages/nextjs/components/ui/alert-dialog.tsx @@ -0,0 +1,190 @@ +"use client"; + +import * as React from "react"; +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; +import { cn } from "~~/lib/utils"; + +const variantClasses = { + default: "bg-[#A0153E] text-white hover:bg-[#B91C4C]", + ghost: "bg-transparent text-neutral-500 hover:text-neutral-300", + outline: "bg-transparent border border-[#2A2A2A] text-white hover:bg-[#121212]", +} as const; + +const sizeClasses = { + default: "h-10 px-4 py-2 text-sm", + sm: "h-9 px-3 py-2 text-sm", +} as const; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & { + variant?: keyof typeof variantClasses; + size?: keyof typeof sizeClasses; +}) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & { + variant?: keyof typeof variantClasses; + size?: keyof typeof sizeClasses; +}) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/start.sh b/start.sh index a8aa137..402db93 100755 --- a/start.sh +++ b/start.sh @@ -26,11 +26,6 @@ until curl -s -X POST --data '{"jsonrpc":"2.0","method":"eth_blockNumber","param done echo " ✅ Node ready" -# Reset all account nonces on the fresh node so MetaMask stays in sync -echo " Resetting account nonces..." -curl -s -X POST --data '{"jsonrpc":"2.0","method":"hardhat_reset","params":[],"id":1}' http://localhost:8545 > /dev/null 2>&1 -echo " ✅ Nonces reset" - echo "" echo "📦 Deploying contracts..." yarn deploy