From 7192f82add953a8243c5bbc251632910cae5c0bb Mon Sep 17 00:00:00 2001 From: SamKry <87971518+SamKry@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:58:40 +0100 Subject: [PATCH] start fixing frontend --- crypto_clash_frontend/app/clash/Commit.tsx | 464 +++++++++++++++--- crypto_clash_frontend/app/clash/GameList.tsx | 47 +- crypto_clash_frontend/app/clash/GameModal.tsx | 198 +++++++- crypto_clash_frontend/app/clash/Reveal.tsx | 363 +++++++++++--- docs/DUAL_MODE_IMPLEMENTATION.md | 267 ++++++++++ 5 files changed, 1156 insertions(+), 183 deletions(-) create mode 100644 docs/DUAL_MODE_IMPLEMENTATION.md diff --git a/crypto_clash_frontend/app/clash/Commit.tsx b/crypto_clash_frontend/app/clash/Commit.tsx index 32f4304..1c7a647 100644 --- a/crypto_clash_frontend/app/clash/Commit.tsx +++ b/crypto_clash_frontend/app/clash/Commit.tsx @@ -18,6 +18,16 @@ interface CommitProps { gameDetails: GameDetails | null; setSecret: (secret: string) => void; savePlayMove: (playMove: string) => void; + // MinusOne mode props + selectedMove1?: string | null; + setSelectedMove1?: (move: string | null) => void; + selectedMove2?: string | null; + setSelectedMove2?: (move: string | null) => void; + secret1?: string; + setSecret1?: (secret: string) => void; + secret2?: string; + setSecret2?: (secret: string) => void; + isWithdrawPhase?: boolean; } type MoveName = "Rock" | "Paper" | "Scissors"; @@ -40,10 +50,21 @@ export default function Commit({ setSecret, savePlayMove, whoAmI, - gameDetails + gameDetails, + selectedMove1, + setSelectedMove1, + selectedMove2, + setSelectedMove2, + secret1, + setSecret1, + secret2, + setSecret2, + isWithdrawPhase = false, }: Readonly) { const [loading, setLoading] = useState(false); const [playMove, setPlayMove] = useState(""); + const [playMove1, setPlayMove1] = useState(""); + const [playMove2, setPlayMove2] = useState(""); const [selfPlayed, setSelfPlayed] = useState(""); const [opponentPlayed, setOpponentPlayed] = useState(""); const [bothPlayed, setBothPlayed] = useState(""); @@ -52,17 +73,32 @@ export default function Commit({ const [commitTimeLeft, setCommitTimeLeft] = useState(0); const [timeoutExpired, setTimeoutExpired] = useState(false); + const isMinusOne = gameDetails?.gameMode === "minusone"; + // Update encrypted move when move or secret changes useEffect(() => { - if (selectedMove && secret) { + if (isMinusOne && !isWithdrawPhase) { + // MinusOne initial commit: two moves + if (selectedMove1 && secret1) { + const clearMove1 = `${selectedMove1}-${secret1}`; + const hash1 = Web3.utils.keccak256(clearMove1); + setPlayMove1(hash1); + } + if (selectedMove2 && secret2) { + const clearMove2 = `${selectedMove2}-${secret2}`; + const hash2 = Web3.utils.keccak256(clearMove2); + setPlayMove2(hash2); + } + } else if (selectedMove && secret) { + // Classic mode or withdrawal phase: single move/choice const clearMove = `${selectedMove}-${secret}`; - // Use keccak256 (Ethereum's standard hash function) const hash = Web3.utils.keccak256(clearMove); setPlayMove(hash); - // Persist to sessionStorage through parent - savePlayMove(hash); + if (!isWithdrawPhase) { + savePlayMove(hash); + } } - }, [selectedMove, secret, savePlayMove]); + }, [selectedMove, secret, selectedMove1, secret1, selectedMove2, secret2, isMinusOne, isWithdrawPhase, savePlayMove]); // Auto-check if both players have committed and trigger callback useEffect(() => { @@ -75,9 +111,18 @@ export default function Commit({ const checkSelfPlayed = async () => { try { - const encrMove = gameDetails[whoAmI === "player1" ? "playerA" : "playerB"].encrMove; - - setSelfPlayed(Number(encrMove) !== 0 ? "true" : "false"); + const player = gameDetails[whoAmI === "player1" ? "playerA" : "playerB"]; + + if (isMinusOne && !isWithdrawPhase) { + // Check hash1 for initial commit phase + setSelfPlayed(player.hash1 && Number(player.hash1) !== 0 ? "true" : "false"); + } else if (isMinusOne && isWithdrawPhase) { + // Check wHash for withdrawal commit phase + setSelfPlayed(player.wHash && Number(player.wHash) !== 0 ? "true" : "false"); + } else { + // Classic mode: check encrMove + setSelfPlayed(player.encrMove && Number(player.encrMove) !== 0 ? "true" : "false"); + } } catch (err: any) { console.error("Auto-check self played failed:", err.message); } @@ -88,8 +133,15 @@ export default function Commit({ const checkOpponentPlayed = async () => { try { const opponentKey = whoAmI === "player1" ? "playerB" : "playerA"; - const encrMove = gameDetails[opponentKey].encrMove; - setOpponentPlayed(Number(encrMove) !== 0 ? "true" : "false"); + const opponent = gameDetails[opponentKey]; + + if (isMinusOne && !isWithdrawPhase) { + setOpponentPlayed(opponent.hash1 && Number(opponent.hash1) !== 0 ? "true" : "false"); + } else if (isMinusOne && isWithdrawPhase) { + setOpponentPlayed(opponent.wHash && Number(opponent.wHash) !== 0 ? "true" : "false"); + } else { + setOpponentPlayed(opponent.encrMove && Number(opponent.encrMove) !== 0 ? "true" : "false"); + } } catch (err: any) { console.error("Auto-check opponent played failed:", err.message); } @@ -100,10 +152,22 @@ export default function Commit({ // Check immediately on mount or when dependencies change const checkBothPlayed = async () => { try { - const playerAEncrMove = gameDetails.playerA.encrMove; - const playerBEncrMove = gameDetails.playerB.encrMove; - - const res = Number(playerAEncrMove) !== 0 && Number(playerBEncrMove) !== 0; + let res: boolean = false; + + if (isMinusOne && !isWithdrawPhase) { + const playerAHash = gameDetails.playerA.hash1; + const playerBHash = gameDetails.playerB.hash1; + res = !!(playerAHash && playerBHash && Number(playerAHash) !== 0 && Number(playerBHash) !== 0); + } else if (isMinusOne && isWithdrawPhase) { + const playerAWHash = gameDetails.playerA.wHash; + const playerBWHash = gameDetails.playerB.wHash; + res = !!(playerAWHash && playerBWHash && Number(playerAWHash) !== 0 && Number(playerBWHash) !== 0); + } else { + const playerAEncrMove = gameDetails.playerA.encrMove; + const playerBEncrMove = gameDetails.playerB.encrMove; + res = !!(playerAEncrMove && playerBEncrMove && Number(playerAEncrMove) !== 0 && Number(playerBEncrMove) !== 0); + } + console.log("Both played check:", res); if (res) { setBothPlayed("true"); @@ -118,7 +182,14 @@ export default function Commit({ // Check commit timeout const checkCommitTimeout = async () => { try { - const timeLeft = await contract.methods.commitTimeLeft(gameDetails.returnGameId).call(); + let timeLeft; + if (isMinusOne) { + // MinusOne uses getTimeLeft() for all phases + timeLeft = await contract.methods.getTimeLeft(gameDetails.returnGameId).call(); + } else { + // Classic uses commitTimeLeft() + timeLeft = await contract.methods.commitTimeLeft(gameDetails.returnGameId).call(); + } console.log("Commit time left:", timeLeft); setCommitTimeLeft(Number(timeLeft)); if (Number(timeLeft) <= 0) { @@ -145,11 +216,35 @@ export default function Commit({ // Commit phase read-only handlers const handlePlay = async () => { - if (!contract || !web3 || !account || !playMove) return; + if (!contract || !web3 || !account) return; + setLoading(true); try { - // playMove should be a hex string (bytes32) - const tx = contract.methods.play(gameDetails?.returnGameId, playMove); + let tx; + + if (isMinusOne && !isWithdrawPhase) { + // MinusOne initial commit: commitInitialMoves(gameId, hash1, hash2) + if (!playMove1 || !playMove2) { + showToast("Please select both moves and enter secrets", "error"); + return; + } + tx = contract.methods.commitInitialMoves(gameDetails?.returnGameId, playMove1, playMove2); + } else if (isMinusOne && isWithdrawPhase) { + // MinusOne withdrawal commit: commitWithdraw(gameId, wHash) + if (!playMove) { + showToast("Please select withdrawal choice and enter secret", "error"); + return; + } + tx = contract.methods.commitWithdraw(gameDetails?.returnGameId, playMove); + } else { + // Classic mode: play(gameId, hash) + if (!playMove) { + showToast("Please select a move and enter secret", "error"); + return; + } + tx = contract.methods.play(gameDetails?.returnGameId, playMove); + } + const gas = await tx.estimateGas({ from: account }); const result = await (globalThis as any).ethereum.request({ method: "eth_sendTransaction", @@ -163,10 +258,10 @@ export default function Commit({ }, ], }); - showToast("Play tx sent: " + result, "success"); + showToast("Commit tx sent: " + result, "success"); setMoveSubmitted(true); } catch (err: any) { - showToast("Play failed: " + err.message, "error"); + showToast("Commit failed: " + err.message, "error"); } finally { setLoading(false); } @@ -174,16 +269,43 @@ export default function Commit({ const regenerateSecret = () => { const randomHex = Math.random().toString(16).slice(2, 18); - setSecret(randomHex); + if (isMinusOne && !isWithdrawPhase) { + // For MinusOne, we might want separate secrets + // For now, let's keep them simple + setSecret1?.(randomHex); + } else { + setSecret(randomHex); + } + }; + + const regenerateSecret2 = () => { + const randomHex = Math.random().toString(16).slice(2, 18); + setSecret2?.(randomHex); }; const handleSecretChange = (value: string) => { setSecret(value); }; + + const handleSecret1Change = (value: string) => { + setSecret1?.(value); + }; + + const handleSecret2Change = (value: string) => { + setSecret2?.(value); + }; const handleMoveSelect = (move: string) => { setSelectedMove(move); }; + + const handleMove1Select = (move: string) => { + setSelectedMove1?.(move); + }; + + const handleMove2Select = (move: string) => { + setSelectedMove2?.(move); + }; const handleResolveTimeout = async () => { if (!contract || !web3 || !account) return; @@ -331,76 +453,264 @@ export default function Commit({ ) : ( <> - {/* Move Selection */} -
-

- Choose your move: -

-
- {(["1", "2", "3"] as const).map((move) => ( - - ))} -
-
+ {/* Move Selection - Different UI for MinusOne vs Classic */} + {isMinusOne && !isWithdrawPhase ? ( + // MinusOne Mode: Select TWO different moves + <> +
+

+ Choose your FIRST move: +

+
+ {(["1", "2", "3"] as const).map((move) => ( + + ))} +
+
- {/* Secret Input */} -
- -
- handleSecretChange(e.target.value)} - placeholder="Your secret passphrase" - className="flex-1" - /> - -
-

- Keep this secret safe! It's needed to reveal your move later. -

-
+
+ +
+ handleSecret1Change(e.target.value)} + placeholder="Your secret passphrase" + className="flex-1" + /> + +
+
+ +
+

+ Choose your SECOND move (must be different): +

+
+ {(["1", "2", "3"] as const).map((move) => ( + + ))} +
+
+ +
+ +
+ handleSecret2Change(e.target.value)} + placeholder="Your secret passphrase" + className="flex-1" + /> + +
+

+ Keep both secrets safe! You'll need them to reveal your moves later. +

+
+ + ) : isWithdrawPhase ? ( + // Withdrawal Phase: Choose which move to withdraw (1 or 2) + <> +
+

+ 🎯 Withdrawal Phase +

+

+ Choose which move to WITHDRAW (1 or 2). The remaining move will be used in the final battle! +

+
+
+

+ Which move do you want to withdraw? +

+
+ {(["1", "2"] as const).map((choice) => ( + + ))} +
+
+ +
+ +
+ handleSecretChange(e.target.value)} + placeholder="Your secret passphrase" + className="flex-1" + /> + +
+

+ This secret is for your withdrawal choice. Keep it safe! +

+
+ + ) : ( + // Classic Mode: Select ONE move + <> +
+

+ Choose your move: +

+
+ {(["1", "2", "3"] as const).map((move) => ( + + ))} +
+
+ + {/* Secret Input */} +
+ +
+ handleSecretChange(e.target.value)} + placeholder="Your secret passphrase" + className="flex-1" + /> + +
+

+ Keep this secret safe! It's needed to reveal your move later. +

+
+ + )} {/* Encrypted Move Display */}
-
- - {playMove || "Select a move and enter a secret"} - -
+ {isMinusOne && !isWithdrawPhase ? ( + <> +
+

Move 1:

+ + {playMove1 || "Select first move and enter secret"} + +
+
+

Move 2:

+ + {playMove2 || "Select second move and enter secret"} + +
+ + ) : ( +
+ + {playMove || "Select a move/choice and enter a secret"} + +
+ )}
{/* Action Buttons */}
diff --git a/crypto_clash_frontend/app/clash/GameList.tsx b/crypto_clash_frontend/app/clash/GameList.tsx index 90bc422..24631f0 100644 --- a/crypto_clash_frontend/app/clash/GameList.tsx +++ b/crypto_clash_frontend/app/clash/GameList.tsx @@ -181,17 +181,44 @@ export default function GameList({ }; const getGamePhase = (game: GameDetails) => { - const playerARevealed = Number(game.playerA.move) !== 0; - const playerBRevealed = Number(game.playerB.move) !== 0; - const playerACommitted = Number(game.playerA.encrMove) !== 0; - const playerBCommitted = Number(game.playerB.encrMove) !== 0; - - if (playerARevealed && playerBRevealed) { - return { phase: "Outcome", color: "bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200" }; - } else if (playerACommitted && playerBCommitted) { - return { phase: "Reveal", color: "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200" }; + const isMinusOne = game.gameMode === "minusone"; + + if (isMinusOne) { + // MinusOne game phases + const playerARevealed1 = Number(game.playerA.move1) !== 0; + const playerBRevealed1 = Number(game.playerB.move1) !== 0; + const playerAWithdrawn = Number(game.playerA.withdrawn) !== 0; + const playerBWithdrawn = Number(game.playerB.withdrawn) !== 0; + const playerACommitted1 = Number(game.playerA.hash1) !== 0; + const playerBCommitted1 = Number(game.playerB.hash1) !== 0; + const playerACommittedW = Number(game.playerA.wHash) !== 0; + const playerBCommittedW = Number(game.playerB.wHash) !== 0; + + if (playerAWithdrawn && playerBWithdrawn) { + return { phase: "Done", color: "bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200" }; + } else if (playerACommittedW && playerBCommittedW) { + return { phase: "Withdraw Reveal", color: "bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200" }; + } else if (playerARevealed1 && playerBRevealed1) { + return { phase: "Withdraw Commit", color: "bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200" }; + } else if (playerACommitted1 && playerBCommitted1) { + return { phase: "Initial Reveal", color: "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200" }; + } else { + return { phase: "Initial Commit", color: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200" }; + } } else { - return { phase: "Commit", color: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200" }; + // Classic game phases + const playerARevealed = Number(game.playerA.move) !== 0; + const playerBRevealed = Number(game.playerB.move) !== 0; + const playerACommitted = Number(game.playerA.encrMove) !== 0; + const playerBCommitted = Number(game.playerB.encrMove) !== 0; + + if (playerARevealed && playerBRevealed) { + return { phase: "Outcome", color: "bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200" }; + } else if (playerACommitted && playerBCommitted) { + return { phase: "Reveal", color: "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200" }; + } else { + return { phase: "Commit", color: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200" }; + } } }; diff --git a/crypto_clash_frontend/app/clash/GameModal.tsx b/crypto_clash_frontend/app/clash/GameModal.tsx index 537933e..5ea69a3 100644 --- a/crypto_clash_frontend/app/clash/GameModal.tsx +++ b/crypto_clash_frontend/app/clash/GameModal.tsx @@ -9,8 +9,16 @@ import { showErrorToast } from "@/app/lib/toast"; export type Player = { addr: string; bet: string; - encrMove: string; - move: number; + // Classic mode fields + encrMove?: string; + move?: number; + // MinusOne mode fields + hash1?: string; + hash2?: string; + move1?: number; + move2?: number; + wHash?: string; + withdrawn?: number; nickname: string; }; @@ -22,6 +30,7 @@ export type GameDetails = { isActive: boolean; returnGameId: number; gameMode?: string; // "classic" or "minusone" + phase?: number; // GamePhase for minusone mode }; interface GameModalProps { @@ -45,20 +54,38 @@ export default function GameModal({ web3, setStatus, }: Readonly) { - const [phase, setPhase] = useState<"commit" | "reveal">("commit"); + const [phase, setPhase] = useState<"commit" | "reveal" | "withdrawCommit" | "withdrawReveal">("commit"); const [whoAmI, setWhoAmI] = useState<"player1" | "player2" | "">(""); const [gameDetails, setGameDetails] = useState(null); + + // Classic mode state const [selectedMove, setSelectedMove] = useState(null); const [secret, setSecret] = useState(""); + + // MinusOne mode state + const [selectedMove1, setSelectedMove1] = useState(null); + const [selectedMove2, setSelectedMove2] = useState(null); + const [secret1, setSecret1] = useState(""); + const [secret2, setSecret2] = useState(""); + const [withdrawChoice, setWithdrawChoice] = useState(null); + const [withdrawSecret, setWithdrawSecret] = useState(""); // Helper function to generate game-specific storage key const getGameStorageKey = () => `game_${gameDetails?.returnGameId}`; // Game storage object structure type GameStorage = { - secret: string; - selectedMove: string | null; - playMove: string; + // Classic mode + secret?: string; + selectedMove?: string | null; + playMove?: string; + // MinusOne mode + secret1?: string; + secret2?: string; + selectedMove1?: string | null; + selectedMove2?: string | null; + withdrawChoice?: string | null; + withdrawSecret?: string; timestamp?: number; }; @@ -101,8 +128,16 @@ export default function GameModal({ if (storedData) { try { const parsed: GameStorage = JSON.parse(storedData); + // Classic mode if (parsed.secret) setSecret(parsed.secret); if (parsed.selectedMove) setSelectedMove(parsed.selectedMove); + // MinusOne mode + if (parsed.secret1) setSecret1(parsed.secret1); + if (parsed.secret2) setSecret2(parsed.secret2); + if (parsed.selectedMove1) setSelectedMove1(parsed.selectedMove1); + if (parsed.selectedMove2) setSelectedMove2(parsed.selectedMove2); + if (parsed.withdrawChoice) setWithdrawChoice(parsed.withdrawChoice); + if (parsed.withdrawSecret) setWithdrawSecret(parsed.withdrawSecret); } catch (err) { console.error("Failed to parse stored game data:", err); } @@ -111,7 +146,7 @@ export default function GameModal({ const saveGameData = (updates: Partial) => { const storedData = sessionStorage.getItem(getGameStorageKey()); - let currentData: GameStorage = { secret: "", selectedMove: null, playMove: "", timestamp: Date.now() }; + let currentData: GameStorage = { timestamp: Date.now() }; if (storedData) { try { @@ -125,6 +160,7 @@ export default function GameModal({ sessionStorage.setItem(getGameStorageKey(), JSON.stringify(updatedData)); }; + // Classic mode save functions const saveSecret = (value: string) => { setSecret(value); saveGameData({ secret: value }); @@ -141,6 +177,43 @@ export default function GameModal({ saveGameData({ playMove }); }; + // MinusOne mode save functions + const saveSecret1 = (value: string) => { + setSecret1(value); + saveGameData({ secret1: value }); + }; + + const saveSecret2 = (value: string) => { + setSecret2(value); + saveGameData({ secret2: value }); + }; + + const saveMoveSelection1 = (move: string | null) => { + setSelectedMove1(move); + if (move !== null) { + saveGameData({ selectedMove1: move }); + } + }; + + const saveMoveSelection2 = (move: string | null) => { + setSelectedMove2(move); + if (move !== null) { + saveGameData({ selectedMove2: move }); + } + }; + + const saveWithdrawChoice = (choice: string | null) => { + setWithdrawChoice(choice); + if (choice !== null) { + saveGameData({ withdrawChoice: choice }); + } + }; + + const saveWithdrawSecret = (value: string) => { + setWithdrawSecret(value); + saveGameData({ withdrawSecret: value }); + }; + useEffect(() => { const fetchPlayerInfo = async () => { @@ -163,23 +236,44 @@ export default function GameModal({ console.log("Game details:", details); setGameDetails(details); - // Determine the correct phase based on game state - const playerAHasMove = Number(details.playerA.encrMove) !== 0; - const playerBHasMove = Number(details.playerB.encrMove) !== 0; - const playerARevealed = Number(details.playerA.move) !== 0; - const playerBRevealed = Number(details.playerB.move) !== 0; + // Determine game mode + const isMinusOne = details.gameMode === "minusone"; - // If both players have revealed their moves, show reveal phase (with results) - if (playerARevealed && playerBRevealed) { - setPhase("reveal"); - } - // If both players have committed but not revealed, show reveal phase - else if (playerAHasMove && playerBHasMove) { - setPhase("reveal"); - } - // Otherwise, show commit phase - else { - setPhase("commit"); + if (isMinusOne) { + // MinusOne mode: use phase enum + // GamePhase enum: Reg=0, InitC=1, FirstR=2, WithdC=3, WithdR=4, Done=5 + const gamePhase = Number(details.phase); + + if (gamePhase === 5) { // Done + setPhase("reveal"); // Show final results + } else if (gamePhase === 4) { // WithdR + setPhase("withdrawReveal"); + } else if (gamePhase === 3) { // WithdC + setPhase("withdrawCommit"); + } else if (gamePhase === 2) { // FirstR + setPhase("reveal"); + } else { // InitC or Reg + setPhase("commit"); + } + } else { + // Classic mode: check encrMove and move fields + const playerAHasMove = details.playerA.encrMove && Number(details.playerA.encrMove) !== 0; + const playerBHasMove = details.playerB.encrMove && Number(details.playerB.encrMove) !== 0; + const playerARevealed = details.playerA.move && Number(details.playerA.move) !== 0; + const playerBRevealed = details.playerB.move && Number(details.playerB.move) !== 0; + + // If both players have revealed their moves, show reveal phase (with results) + if (playerARevealed && playerBRevealed) { + setPhase("reveal"); + } + // If both players have committed but not revealed, show reveal phase + else if (playerAHasMove && playerBHasMove) { + setPhase("reveal"); + } + // Otherwise, show commit phase + else { + setPhase("commit"); + } } } catch (err: any) { showErrorToast("Error fetching game details: " + err.message); @@ -218,6 +312,12 @@ export default function GameModal({ setPhase("commit"); setSelectedMove(null); setSecret(""); + setSelectedMove1(null); + setSelectedMove2(null); + setSecret1(""); + setSecret2(""); + setWithdrawChoice(null); + setWithdrawSecret(""); onClose(); }; @@ -234,9 +334,10 @@ export default function GameModal({ {gameId ? `Game #${gameId}` : "Game"}

- {phase === "commit" - ? "Commit your move" - : "Reveal your move"} + {phase === "commit" && "Commit your move"} + {phase === "reveal" && "Reveal your move"} + {phase === "withdrawCommit" && "Choose which move to withdraw"} + {phase === "withdrawReveal" && "Reveal your withdrawal choice"}

)} @@ -372,42 +504,133 @@ export default function Reveal({ {/* Moves Comparison */}

- Final Moves + {isMinusOne ? "Final Moves (After Withdrawal)" : "Final Moves"}

-
- {/* Your Move (always on left) */} -
- - {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.icon} - - - You - - - {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.name} - -
+ + {isMinusOne && gameDetails ? ( +
+ {/* Show all moves including withdrawn ones for MinusOne */} +
+ {/* Your Moves */} +
+ You +
+
+ Move 1 + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move1 : gameDetails.playerB.move1)]?.icon} + + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move1 : gameDetails.playerB.move1)]?.name} + + {(whoAmI === "player1" ? gameDetails.playerA.withdrawn : gameDetails.playerB.withdrawn) === 1 && ( + Withdrawn + )} +
+
+ Move 2 + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move2 : gameDetails.playerB.move2)]?.icon} + + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move2 : gameDetails.playerB.move2)]?.name} + + {(whoAmI === "player1" ? gameDetails.playerA.withdrawn : gameDetails.playerB.withdrawn) === 2 && ( + Withdrawn + )} +
+
+
- {/* VS */} -
- - VS - -
+ {/* VS */} +
+ VS +
- {/* Opponent Move (always on right) */} -
- - {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.icon} - - - Opponent - - - {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.name} - + {/* Opponent Moves */} +
+ Opponent +
+
+ Move 1 + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move1 : gameDetails.playerA.move1)]?.icon} + + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move1 : gameDetails.playerA.move1)]?.name} + + {(whoAmI === "player1" ? gameDetails.playerB.withdrawn : gameDetails.playerA.withdrawn) === 1 && ( + Withdrawn + )} +
+
+ Move 2 + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move2 : gameDetails.playerA.move2)]?.icon} + + + {MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move2 : gameDetails.playerA.move2)]?.name} + + {(whoAmI === "player1" ? gameDetails.playerB.withdrawn : gameDetails.playerA.withdrawn) === 2 && ( + Withdrawn + )} +
+
+
+
-
+ ) : ( +
+ {/* Classic game - Your Move (always on left) */} +
+ + {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.icon} + + + You + + + {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.name} + +
+ + {/* VS */} +
+ + VS + +
+ + {/* Classic game - Opponent Move (always on right) */} +
+ + {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.icon} + + + Opponent + + + {gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.name} + +
+
+ )}
{/* Outcome Section */} diff --git a/docs/DUAL_MODE_IMPLEMENTATION.md b/docs/DUAL_MODE_IMPLEMENTATION.md new file mode 100644 index 0000000..b0cc908 --- /dev/null +++ b/docs/DUAL_MODE_IMPLEMENTATION.md @@ -0,0 +1,267 @@ +# Dual-Mode Frontend Implementation + +This document describes the frontend implementation that supports both Classic and Minus One game modes. + +## Overview + +The frontend has been updated to seamlessly work with two different smart contracts: +- **Game.sol** (Classic Mode): Standard Rock-Paper-Scissors with 2 phases +- **GameMinusOne.sol** (Minus One Mode): Squid Game-inspired variant with 6 phases + +## Game Modes Comparison + +### Classic Mode +**Phases:** +1. Commit Phase (both players submit encrypted moves) +2. Reveal Phase (both players reveal their moves) + +**Functions:** +- `register(gameId, nickname)` - Join/create game +- `play(gameId, encrMove)` - Submit encrypted move +- `reveal(gameId, clearMove)` - Reveal move +- `commitTimeLeft()` / `revealTimeLeft()` - Check timeouts +- `getOutcome(gameId)` - Get game result + +**Data Structure:** +```typescript +Player { + addr: string; + nickname: string; + encrMove?: string; // Classic only + move?: string; // Classic only +} +``` + +### Minus One Mode +**Phases:** +1. Registration +2. Initial Commit (both players commit 2 moves) +3. First Reveal (reveal both moves) +4. Withdraw Commit (commit which move to withdraw) +5. Withdraw Reveal (reveal withdrawal choice) +6. Done + +**Functions:** +- `register(gameId, nickname)` - Join/create game +- `commitInitialMoves(gameId, hash1, hash2)` - Submit 2 encrypted moves +- `revealInitialMoves(gameId, clear1, clear2)` - Reveal both moves +- `commitWithdraw(gameId, wHash)` - Submit withdrawal choice +- `withdrawMove(gameId, clear)` - Reveal withdrawal +- `getTimeLeft(gameId)` - Universal timeout checker +- `getOutcome(gameId)` - Get game result + +**Data Structure:** +```typescript +Player { + addr: string; + nickname: string; + hash1?: string; // MinusOne only + hash2?: string; // MinusOne only + move1?: string; // MinusOne only + move2?: string; // MinusOne only + wHash?: string; // MinusOne only + withdrawn?: string; // MinusOne only (1 or 2) +} +``` + +## Implementation Details + +### Type System +The frontend uses optional fields to support both modes: + +```typescript +interface Player { + addr: string; + nickname: string; + // Classic fields + encrMove?: string; + move?: string; + // MinusOne fields + hash1?: string; + hash2?: string; + move1?: string; + move2?: string; + wHash?: string; + withdrawn?: string; +} + +interface GameDetails { + playerA: Player; + playerB: Player; + initialBet: string; + outcome: number; + isActive: boolean; + returnGameId: number; + gameMode?: string; // "classic" or "minusone" +} +``` + +### Component Updates + +#### GameModal.tsx +- **Purpose:** Manages game state and routes to correct phase component +- **Changes:** + - Added `gameMode` field to GameDetails + - Phase detection logic checks `gameMode` and appropriate fields + - Storage structure expanded for MinusOne state (move1/2, secret1/2, withdrawChoice) + +#### Commit.tsx +- **Purpose:** Handles all commit phases (move commits and withdrawal commits) +- **Changes:** + - Added MinusOne-specific props: `selectedMove1`, `selectedMove2`, `secret1`, `secret2`, `withdrawChoice` + - Hash generation adapts based on mode: + - Classic: `keccak256(move-password)` + - MinusOne initial: Two hashes `keccak256(move1-password1)`, `keccak256(move2-password2)` + - MinusOne withdrawal: `keccak256(1-password)` or `keccak256(2-password)` + - UI conditionally renders: + - Classic: Single move selector + - MinusOne initial: Two move selectors with different colored borders + - MinusOne withdrawal: Choice between withdrawing move 1 or 2 + - Contract calls: + - Classic: `play(gameId, encrMove)` + - MinusOne initial: `commitInitialMoves(gameId, hash1, hash2)` + - MinusOne withdrawal: `commitWithdraw(gameId, wHash)` + +#### Reveal.tsx +- **Purpose:** Handles all reveal phases (move reveals and withdrawal reveals) +- **Changes:** + - Added MinusOne-specific props: `selectedMove1`, `selectedMove2`, `secret1`, `secret2`, `isWithdrawPhase` + - State checks adapted: + - Classic: Check `move` field + - MinusOne initial: Check `move1` field + - MinusOne withdrawal: Check `withdrawn` field + - Reveal function calls: + - Classic: `reveal(gameId, clearMove)` + - MinusOne initial: `revealInitialMoves(gameId, clear1, clear2)` + - MinusOne withdrawal: `withdrawMove(gameId, clear)` + - UI conditionally displays: + - Classic: Single move display + - MinusOne initial: Both moves displayed side-by-side + - MinusOne withdrawal: Withdrawal choice (1️⃣ or 2️⃣) + - Final outcome shows: + - Classic: Final move from each player + - MinusOne: All 4 moves with withdrawn ones marked (grayed out with "Withdrawn" label) + +#### GameList.tsx +- **Purpose:** Lists available games and shows their status +- **Changes:** + - `getGamePhase()` function checks `gameMode` and appropriate fields: + - Classic: Returns "Commit", "Reveal", or "Outcome" + - MinusOne: Returns "Initial Commit", "Initial Reveal", "Withdraw Commit", "Withdraw Reveal", or "Done" + - Game mode badge displays "Classic" or "Minus One" + - Phase colors: + - Yellow: Commit phases + - Blue: Initial reveal + - Amber: Withdraw commit + - Orange: Withdraw reveal + - Purple: Complete/Done + +### Hash Generation + +All hashing uses Keccak256 with Web3.js: + +```typescript +// Classic mode +const hash = web3.utils.keccak256(web3.utils.utf8ToHex(`${move}-${secret}`)); + +// MinusOne initial moves +const hash1 = web3.utils.keccak256(web3.utils.utf8ToHex(`${move1}-${secret1}`)); +const hash2 = web3.utils.keccak256(web3.utils.utf8ToHex(`${move2}-${secret2}`)); + +// MinusOne withdrawal +const wHash = web3.utils.keccak256(web3.utils.utf8ToHex(`${withdrawChoice}-${secret}`)); +``` + +### Session Storage + +Game state persists across page refreshes using sessionStorage: + +**Classic Mode:** +```typescript +{ + selectedMove: string, + secret: string, + expiresAt: number +} +``` + +**MinusOne Mode:** +```typescript +{ + selectedMove1: string, + selectedMove2: string, + secret1: string, + secret2: string, + withdrawChoice: string, + expiresAt: number +} +``` + +Storage expires after 1 hour to prevent stale data. + +## Testing Checklist + +### Classic Mode +- [ ] Create new game with "Classic Mode" +- [ ] Join game as Player B +- [ ] Both players commit moves +- [ ] Both players reveal moves +- [ ] Verify correct outcome (win/lose/draw) +- [ ] Check ETH payouts + +### Minus One Mode +- [ ] Create new game with "Minus One Mode" +- [ ] Join game as Player B +- [ ] Both players commit 2 initial moves +- [ ] Both players reveal initial moves +- [ ] Verify both moves displayed correctly +- [ ] Both players commit withdrawal choice +- [ ] Both players reveal withdrawal +- [ ] Verify final moves (1 active, 1 withdrawn per player) +- [ ] Check correct outcome based on remaining moves +- [ ] Check ETH payouts + +### Edge Cases +- [ ] Timeout handling in Classic mode +- [ ] Timeout handling in MinusOne mode (different phases) +- [ ] Page refresh preserves state (both modes) +- [ ] GameList shows correct phase for both modes +- [ ] Switching between active games (mixed modes) + +## Deployment Steps + +1. **Deploy GameMinusOne contract:** + ```bash + cd crypto_clash_contract + npx hardhat run scripts/deploy.ts --network + ``` + +2. **Update config.json:** + ```json + { + "GAME_CONTRACT_ADDRESS": "0x...", + "GAME_MINUSONE_CONTRACT_ADDRESS": "0x...", + "NETWORK_NAME": "...", + "CHAIN_ID": ... + } + ``` + +3. **Copy config to frontend:** + ```bash + cd crypto_clash_frontend + node copy_config.js + ``` + +4. **Build and deploy frontend:** + ```bash + npm run build + npm run start + ``` + +## Future Enhancements + +- Contract mode detection could be automatic (read contract bytecode or interface) +- Support switching contracts without page reload +- Statistics tracking for each mode separately +- Leaderboard for each mode +- Tournament mode supporting both game types