start fixing frontend

This commit is contained in:
SamKry
2025-12-16 15:58:40 +01:00
parent 2711b6ab87
commit 7192f82add
5 changed files with 1156 additions and 183 deletions

View File

@@ -18,6 +18,16 @@ interface CommitProps {
gameDetails: GameDetails | null; gameDetails: GameDetails | null;
setSecret: (secret: string) => void; setSecret: (secret: string) => void;
savePlayMove: (playMove: 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"; type MoveName = "Rock" | "Paper" | "Scissors";
@@ -40,10 +50,21 @@ export default function Commit({
setSecret, setSecret,
savePlayMove, savePlayMove,
whoAmI, whoAmI,
gameDetails gameDetails,
selectedMove1,
setSelectedMove1,
selectedMove2,
setSelectedMove2,
secret1,
setSecret1,
secret2,
setSecret2,
isWithdrawPhase = false,
}: Readonly<CommitProps>) { }: Readonly<CommitProps>) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [playMove, setPlayMove] = useState<string>(""); const [playMove, setPlayMove] = useState<string>("");
const [playMove1, setPlayMove1] = useState<string>("");
const [playMove2, setPlayMove2] = useState<string>("");
const [selfPlayed, setSelfPlayed] = useState<string>(""); const [selfPlayed, setSelfPlayed] = useState<string>("");
const [opponentPlayed, setOpponentPlayed] = useState<string>(""); const [opponentPlayed, setOpponentPlayed] = useState<string>("");
const [bothPlayed, setBothPlayed] = useState<string>(""); const [bothPlayed, setBothPlayed] = useState<string>("");
@@ -52,17 +73,32 @@ export default function Commit({
const [commitTimeLeft, setCommitTimeLeft] = useState<number>(0); const [commitTimeLeft, setCommitTimeLeft] = useState<number>(0);
const [timeoutExpired, setTimeoutExpired] = useState(false); const [timeoutExpired, setTimeoutExpired] = useState(false);
const isMinusOne = gameDetails?.gameMode === "minusone";
// Update encrypted move when move or secret changes // Update encrypted move when move or secret changes
useEffect(() => { 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}`; const clearMove = `${selectedMove}-${secret}`;
// Use keccak256 (Ethereum's standard hash function)
const hash = Web3.utils.keccak256(clearMove); const hash = Web3.utils.keccak256(clearMove);
setPlayMove(hash); setPlayMove(hash);
// Persist to sessionStorage through parent if (!isWithdrawPhase) {
savePlayMove(hash); savePlayMove(hash);
}
} }
}, [selectedMove, secret, savePlayMove]); }, [selectedMove, secret, selectedMove1, secret1, selectedMove2, secret2, isMinusOne, isWithdrawPhase, savePlayMove]);
// Auto-check if both players have committed and trigger callback // Auto-check if both players have committed and trigger callback
useEffect(() => { useEffect(() => {
@@ -75,9 +111,18 @@ export default function Commit({
const checkSelfPlayed = async () => { const checkSelfPlayed = async () => {
try { try {
const encrMove = gameDetails[whoAmI === "player1" ? "playerA" : "playerB"].encrMove; const player = gameDetails[whoAmI === "player1" ? "playerA" : "playerB"];
setSelfPlayed(Number(encrMove) !== 0 ? "true" : "false"); 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) { } catch (err: any) {
console.error("Auto-check self played failed:", err.message); console.error("Auto-check self played failed:", err.message);
} }
@@ -88,8 +133,15 @@ export default function Commit({
const checkOpponentPlayed = async () => { const checkOpponentPlayed = async () => {
try { try {
const opponentKey = whoAmI === "player1" ? "playerB" : "playerA"; const opponentKey = whoAmI === "player1" ? "playerB" : "playerA";
const encrMove = gameDetails[opponentKey].encrMove; const opponent = gameDetails[opponentKey];
setOpponentPlayed(Number(encrMove) !== 0 ? "true" : "false");
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) { } catch (err: any) {
console.error("Auto-check opponent played failed:", err.message); 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 // Check immediately on mount or when dependencies change
const checkBothPlayed = async () => { const checkBothPlayed = async () => {
try { try {
const playerAEncrMove = gameDetails.playerA.encrMove; let res: boolean = false;
const playerBEncrMove = gameDetails.playerB.encrMove;
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);
}
const res = Number(playerAEncrMove) !== 0 && Number(playerBEncrMove) !== 0;
console.log("Both played check:", res); console.log("Both played check:", res);
if (res) { if (res) {
setBothPlayed("true"); setBothPlayed("true");
@@ -118,7 +182,14 @@ export default function Commit({
// Check commit timeout // Check commit timeout
const checkCommitTimeout = async () => { const checkCommitTimeout = async () => {
try { 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); console.log("Commit time left:", timeLeft);
setCommitTimeLeft(Number(timeLeft)); setCommitTimeLeft(Number(timeLeft));
if (Number(timeLeft) <= 0) { if (Number(timeLeft) <= 0) {
@@ -145,11 +216,35 @@ export default function Commit({
// Commit phase read-only handlers // Commit phase read-only handlers
const handlePlay = async () => { const handlePlay = async () => {
if (!contract || !web3 || !account || !playMove) return; if (!contract || !web3 || !account) return;
setLoading(true); setLoading(true);
try { try {
// playMove should be a hex string (bytes32) let tx;
const tx = contract.methods.play(gameDetails?.returnGameId, playMove);
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 gas = await tx.estimateGas({ from: account });
const result = await (globalThis as any).ethereum.request({ const result = await (globalThis as any).ethereum.request({
method: "eth_sendTransaction", 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); setMoveSubmitted(true);
} catch (err: any) { } catch (err: any) {
showToast("Play failed: " + err.message, "error"); showToast("Commit failed: " + err.message, "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -174,17 +269,44 @@ export default function Commit({
const regenerateSecret = () => { const regenerateSecret = () => {
const randomHex = Math.random().toString(16).slice(2, 18); 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) => { const handleSecretChange = (value: string) => {
setSecret(value); setSecret(value);
}; };
const handleSecret1Change = (value: string) => {
setSecret1?.(value);
};
const handleSecret2Change = (value: string) => {
setSecret2?.(value);
};
const handleMoveSelect = (move: string) => { const handleMoveSelect = (move: string) => {
setSelectedMove(move); setSelectedMove(move);
}; };
const handleMove1Select = (move: string) => {
setSelectedMove1?.(move);
};
const handleMove2Select = (move: string) => {
setSelectedMove2?.(move);
};
const handleResolveTimeout = async () => { const handleResolveTimeout = async () => {
if (!contract || !web3 || !account) return; if (!contract || !web3 || !account) return;
setLoading(true); setLoading(true);
@@ -331,76 +453,264 @@ export default function Commit({
</div> </div>
) : ( ) : (
<> <>
{/* Move Selection */} {/* Move Selection - Different UI for MinusOne vs Classic */}
<div className="mb-8"> {isMinusOne && !isWithdrawPhase ? (
<p className="text-sm text-slate-600 dark:text-slate-300 mb-4 font-medium"> // MinusOne Mode: Select TWO different moves
Choose your move: <>
</p> <div className="mb-6">
<div className="flex gap-4 justify-center"> <p className="text-sm text-slate-600 dark:text-slate-300 mb-4 font-medium">
{(["1", "2", "3"] as const).map((move) => ( Choose your FIRST move:
<button </p>
key={move} <div className="flex gap-4 justify-center">
onClick={() => handleMoveSelect(move)} {(["1", "2", "3"] as const).map((move) => (
className={`flex flex-col items-center justify-center p-6 rounded-lg transition-all transform ${ <button
selectedMove === move key={move}
? "bg-blue-500 text-white shadow-lg scale-110" onClick={() => handleMove1Select(move)}
: "bg-white dark:bg-slate-600 text-slate-700 dark:text-slate-200 shadow hover:shadow-md hover:scale-105" disabled={selectedMove2 === move}
}`} className={`flex flex-col items-center justify-center p-6 rounded-lg transition-all transform ${
> selectedMove1 === move
<span className="text-5xl mb-2">{MOVES[move].icon}</span> ? "bg-blue-500 text-white shadow-lg scale-110"
<span className="font-semibold text-sm">{MOVES[move].name}</span> : selectedMove2 === move
</button> ? "bg-slate-300 dark:bg-slate-600 text-slate-400 dark:text-slate-500 cursor-not-allowed"
))} : "bg-white dark:bg-slate-600 text-slate-700 dark:text-slate-200 shadow hover:shadow-md hover:scale-105"
</div> }`}
</div> >
<span className="text-5xl mb-2">{MOVES[move].icon}</span>
<span className="font-semibold text-sm">{MOVES[move].name}</span>
</button>
))}
</div>
</div>
{/* Secret Input */} <div className="mb-6 bg-white dark:bg-slate-700 p-4 rounded-lg">
<div className="mb-8 bg-white dark:bg-slate-700 p-4 rounded-lg"> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3"> Secret for first move:
Secret: </label>
</label> <div className="flex gap-2">
<div className="flex gap-2"> <Input
<Input type="text"
type="text" value={secret1 || ""}
value={secret} onChange={(e) => handleSecret1Change(e.target.value)}
onChange={(e) => handleSecretChange(e.target.value)} placeholder="Your secret passphrase"
placeholder="Your secret passphrase" className="flex-1"
className="flex-1" />
/> <Button
<Button onClick={regenerateSecret}
onClick={regenerateSecret} variant="secondary"
variant="secondary" disabled={loading}
disabled={loading} >
> 🔄 New
🔄 New </Button>
</Button> </div>
</div> </div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
Keep this secret safe! It's needed to reveal your move later. <div className="mb-6">
</p> <p className="text-sm text-slate-600 dark:text-slate-300 mb-4 font-medium">
</div> Choose your SECOND move (must be different):
</p>
<div className="flex gap-4 justify-center">
{(["1", "2", "3"] as const).map((move) => (
<button
key={move}
onClick={() => handleMove2Select(move)}
disabled={selectedMove1 === move}
className={`flex flex-col items-center justify-center p-6 rounded-lg transition-all transform ${
selectedMove2 === move
? "bg-green-500 text-white shadow-lg scale-110"
: selectedMove1 === move
? "bg-slate-300 dark:bg-slate-600 text-slate-400 dark:text-slate-500 cursor-not-allowed"
: "bg-white dark:bg-slate-600 text-slate-700 dark:text-slate-200 shadow hover:shadow-md hover:scale-105"
}`}
>
<span className="text-5xl mb-2">{MOVES[move].icon}</span>
<span className="font-semibold text-sm">{MOVES[move].name}</span>
</button>
))}
</div>
</div>
<div className="mb-8 bg-white dark:bg-slate-700 p-4 rounded-lg">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
Secret for second move:
</label>
<div className="flex gap-2">
<Input
type="text"
value={secret2 || ""}
onChange={(e) => handleSecret2Change(e.target.value)}
placeholder="Your secret passphrase"
className="flex-1"
/>
<Button
onClick={regenerateSecret2}
variant="secondary"
disabled={loading}
>
🔄 New
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
Keep both secrets safe! You'll need them to reveal your moves later.
</p>
</div>
</>
) : isWithdrawPhase ? (
// Withdrawal Phase: Choose which move to withdraw (1 or 2)
<>
<div className="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900 border-2 border-yellow-300 dark:border-yellow-700 rounded-lg">
<p className="text-yellow-800 dark:text-yellow-200 text-sm mb-2 font-medium">
🎯 Withdrawal Phase
</p>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
Choose which move to WITHDRAW (1 or 2). The remaining move will be used in the final battle!
</p>
</div>
<div className="mb-6">
<p className="text-sm text-slate-600 dark:text-slate-300 mb-4 font-medium">
Which move do you want to withdraw?
</p>
<div className="flex gap-4 justify-center">
{(["1", "2"] as const).map((choice) => (
<button
key={choice}
onClick={() => handleMoveSelect(choice)}
className={`flex flex-col items-center justify-center p-8 rounded-lg transition-all transform ${
selectedMove === choice
? "bg-red-500 text-white shadow-lg scale-110"
: "bg-white dark:bg-slate-600 text-slate-700 dark:text-slate-200 shadow hover:shadow-md hover:scale-105"
}`}
>
<span className="text-6xl mb-2">{choice === "1" ? "1⃣" : "2⃣"}</span>
<span className="font-semibold">Withdraw Move {choice}</span>
<span className="text-xs mt-1 opacity-75">
{choice === "1" ? "Keep move 2" : "Keep move 1"}
</span>
</button>
))}
</div>
</div>
<div className="mb-8 bg-white dark:bg-slate-700 p-4 rounded-lg">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
Secret for withdrawal:
</label>
<div className="flex gap-2">
<Input
type="text"
value={secret}
onChange={(e) => handleSecretChange(e.target.value)}
placeholder="Your secret passphrase"
className="flex-1"
/>
<Button
onClick={regenerateSecret}
variant="secondary"
disabled={loading}
>
🔄 New
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
This secret is for your withdrawal choice. Keep it safe!
</p>
</div>
</>
) : (
// Classic Mode: Select ONE move
<>
<div className="mb-8">
<p className="text-sm text-slate-600 dark:text-slate-300 mb-4 font-medium">
Choose your move:
</p>
<div className="flex gap-4 justify-center">
{(["1", "2", "3"] as const).map((move) => (
<button
key={move}
onClick={() => handleMoveSelect(move)}
className={`flex flex-col items-center justify-center p-6 rounded-lg transition-all transform ${
selectedMove === move
? "bg-blue-500 text-white shadow-lg scale-110"
: "bg-white dark:bg-slate-600 text-slate-700 dark:text-slate-200 shadow hover:shadow-md hover:scale-105"
}`}
>
<span className="text-5xl mb-2">{MOVES[move].icon}</span>
<span className="font-semibold text-sm">{MOVES[move].name}</span>
</button>
))}
</div>
</div>
{/* Secret Input */}
<div className="mb-8 bg-white dark:bg-slate-700 p-4 rounded-lg">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
Secret:
</label>
<div className="flex gap-2">
<Input
type="text"
value={secret}
onChange={(e) => handleSecretChange(e.target.value)}
placeholder="Your secret passphrase"
className="flex-1"
/>
<Button
onClick={regenerateSecret}
variant="secondary"
disabled={loading}
>
🔄 New
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
Keep this secret safe! It's needed to reveal your move later.
</p>
</div>
</>
)}
{/* Encrypted Move Display */} {/* Encrypted Move Display */}
<div className="mb-8 bg-blue-50 dark:bg-blue-900 p-4 rounded-lg"> <div className="mb-8 bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
<label className="block text-sm font-medium text-slate-700 dark:text-blue-200 mb-2"> <label className="block text-sm font-medium text-slate-700 dark:text-blue-200 mb-2">
Encrypted Move (to be sent): {isMinusOne && !isWithdrawPhase ? "Encrypted Moves (to be sent):" : "Encrypted Move (to be sent):"}
</label> </label>
<div className="bg-white dark:bg-slate-700 p-3 rounded border border-blue-200 dark:border-blue-700 overflow-x-auto"> {isMinusOne && !isWithdrawPhase ? (
<code className="text-xs text-slate-600 dark:text-slate-300 font-mono break-all"> <>
{playMove || "Select a move and enter a secret"} <div className="bg-white dark:bg-slate-700 p-3 rounded border border-blue-200 dark:border-blue-700 overflow-x-auto mb-2">
</code> <p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Move 1:</p>
</div> <code className="text-xs text-slate-600 dark:text-slate-300 font-mono break-all">
{playMove1 || "Select first move and enter secret"}
</code>
</div>
<div className="bg-white dark:bg-slate-700 p-3 rounded border border-green-200 dark:border-green-700 overflow-x-auto">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Move 2:</p>
<code className="text-xs text-slate-600 dark:text-slate-300 font-mono break-all">
{playMove2 || "Select second move and enter secret"}
</code>
</div>
</>
) : (
<div className="bg-white dark:bg-slate-700 p-3 rounded border border-blue-200 dark:border-blue-700 overflow-x-auto">
<code className="text-xs text-slate-600 dark:text-slate-300 font-mono break-all">
{playMove || "Select a move/choice and enter a secret"}
</code>
</div>
)}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={handlePlay} onClick={handlePlay}
disabled={loading || !account || !contract || !selectedMove || !secret} disabled={
loading ||
!account ||
!contract ||
(isMinusOne && !isWithdrawPhase ? (!selectedMove1 || !selectedMove2 || !secret1 || !secret2) : (!selectedMove || !secret))
}
variant="primary" variant="primary"
className="w-full py-3 text-lg" className="w-full py-3 text-lg"
> >
{loading ? "Submitting..." : "Submit Move"} {loading ? "Submitting..." : isWithdrawPhase ? "Submit Withdrawal Choice" : "Submit Move"}
</Button> </Button>
</div> </div>
</> </>

View File

@@ -181,17 +181,44 @@ export default function GameList({
}; };
const getGamePhase = (game: GameDetails) => { const getGamePhase = (game: GameDetails) => {
const playerARevealed = Number(game.playerA.move) !== 0; const isMinusOne = game.gameMode === "minusone";
const playerBRevealed = Number(game.playerB.move) !== 0;
const playerACommitted = Number(game.playerA.encrMove) !== 0;
const playerBCommitted = Number(game.playerB.encrMove) !== 0;
if (playerARevealed && playerBRevealed) { if (isMinusOne) {
return { phase: "Outcome", color: "bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200" }; // MinusOne game phases
} else if (playerACommitted && playerBCommitted) { const playerARevealed1 = Number(game.playerA.move1) !== 0;
return { phase: "Reveal", color: "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200" }; 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 { } 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" };
}
} }
}; };

View File

@@ -9,8 +9,16 @@ import { showErrorToast } from "@/app/lib/toast";
export type Player = { export type Player = {
addr: string; addr: string;
bet: string; bet: string;
encrMove: string; // Classic mode fields
move: number; encrMove?: string;
move?: number;
// MinusOne mode fields
hash1?: string;
hash2?: string;
move1?: number;
move2?: number;
wHash?: string;
withdrawn?: number;
nickname: string; nickname: string;
}; };
@@ -22,6 +30,7 @@ export type GameDetails = {
isActive: boolean; isActive: boolean;
returnGameId: number; returnGameId: number;
gameMode?: string; // "classic" or "minusone" gameMode?: string; // "classic" or "minusone"
phase?: number; // GamePhase for minusone mode
}; };
interface GameModalProps { interface GameModalProps {
@@ -45,20 +54,38 @@ export default function GameModal({
web3, web3,
setStatus, setStatus,
}: Readonly<GameModalProps>) { }: Readonly<GameModalProps>) {
const [phase, setPhase] = useState<"commit" | "reveal">("commit"); const [phase, setPhase] = useState<"commit" | "reveal" | "withdrawCommit" | "withdrawReveal">("commit");
const [whoAmI, setWhoAmI] = useState<"player1" | "player2" | "">(""); const [whoAmI, setWhoAmI] = useState<"player1" | "player2" | "">("");
const [gameDetails, setGameDetails] = useState<GameDetails | null>(null); const [gameDetails, setGameDetails] = useState<GameDetails | null>(null);
// Classic mode state
const [selectedMove, setSelectedMove] = useState<string | null>(null); const [selectedMove, setSelectedMove] = useState<string | null>(null);
const [secret, setSecret] = useState<string>(""); const [secret, setSecret] = useState<string>("");
// MinusOne mode state
const [selectedMove1, setSelectedMove1] = useState<string | null>(null);
const [selectedMove2, setSelectedMove2] = useState<string | null>(null);
const [secret1, setSecret1] = useState<string>("");
const [secret2, setSecret2] = useState<string>("");
const [withdrawChoice, setWithdrawChoice] = useState<string | null>(null);
const [withdrawSecret, setWithdrawSecret] = useState<string>("");
// Helper function to generate game-specific storage key // Helper function to generate game-specific storage key
const getGameStorageKey = () => `game_${gameDetails?.returnGameId}`; const getGameStorageKey = () => `game_${gameDetails?.returnGameId}`;
// Game storage object structure // Game storage object structure
type GameStorage = { type GameStorage = {
secret: string; // Classic mode
selectedMove: string | null; secret?: string;
playMove: 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; timestamp?: number;
}; };
@@ -101,8 +128,16 @@ export default function GameModal({
if (storedData) { if (storedData) {
try { try {
const parsed: GameStorage = JSON.parse(storedData); const parsed: GameStorage = JSON.parse(storedData);
// Classic mode
if (parsed.secret) setSecret(parsed.secret); if (parsed.secret) setSecret(parsed.secret);
if (parsed.selectedMove) setSelectedMove(parsed.selectedMove); 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) { } catch (err) {
console.error("Failed to parse stored game data:", err); console.error("Failed to parse stored game data:", err);
} }
@@ -111,7 +146,7 @@ export default function GameModal({
const saveGameData = (updates: Partial<GameStorage>) => { const saveGameData = (updates: Partial<GameStorage>) => {
const storedData = sessionStorage.getItem(getGameStorageKey()); const storedData = sessionStorage.getItem(getGameStorageKey());
let currentData: GameStorage = { secret: "", selectedMove: null, playMove: "", timestamp: Date.now() }; let currentData: GameStorage = { timestamp: Date.now() };
if (storedData) { if (storedData) {
try { try {
@@ -125,6 +160,7 @@ export default function GameModal({
sessionStorage.setItem(getGameStorageKey(), JSON.stringify(updatedData)); sessionStorage.setItem(getGameStorageKey(), JSON.stringify(updatedData));
}; };
// Classic mode save functions
const saveSecret = (value: string) => { const saveSecret = (value: string) => {
setSecret(value); setSecret(value);
saveGameData({ secret: value }); saveGameData({ secret: value });
@@ -141,6 +177,43 @@ export default function GameModal({
saveGameData({ playMove }); 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(() => { useEffect(() => {
const fetchPlayerInfo = async () => { const fetchPlayerInfo = async () => {
@@ -163,23 +236,44 @@ export default function GameModal({
console.log("Game details:", details); console.log("Game details:", details);
setGameDetails(details); setGameDetails(details);
// Determine the correct phase based on game state // Determine game mode
const playerAHasMove = Number(details.playerA.encrMove) !== 0; const isMinusOne = details.gameMode === "minusone";
const playerBHasMove = Number(details.playerB.encrMove) !== 0;
const playerARevealed = Number(details.playerA.move) !== 0;
const playerBRevealed = Number(details.playerB.move) !== 0;
// If both players have revealed their moves, show reveal phase (with results) if (isMinusOne) {
if (playerARevealed && playerBRevealed) { // MinusOne mode: use phase enum
setPhase("reveal"); // GamePhase enum: Reg=0, InitC=1, FirstR=2, WithdC=3, WithdR=4, Done=5
} const gamePhase = Number(details.phase);
// If both players have committed but not revealed, show reveal phase
else if (playerAHasMove && playerBHasMove) { if (gamePhase === 5) { // Done
setPhase("reveal"); setPhase("reveal"); // Show final results
} } else if (gamePhase === 4) { // WithdR
// Otherwise, show commit phase setPhase("withdrawReveal");
else { } else if (gamePhase === 3) { // WithdC
setPhase("commit"); 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) { } catch (err: any) {
showErrorToast("Error fetching game details: " + err.message); showErrorToast("Error fetching game details: " + err.message);
@@ -218,6 +312,12 @@ export default function GameModal({
setPhase("commit"); setPhase("commit");
setSelectedMove(null); setSelectedMove(null);
setSecret(""); setSecret("");
setSelectedMove1(null);
setSelectedMove2(null);
setSecret1("");
setSecret2("");
setWithdrawChoice(null);
setWithdrawSecret("");
onClose(); onClose();
}; };
@@ -234,9 +334,10 @@ export default function GameModal({
{gameId ? `Game #${gameId}` : "Game"} {gameId ? `Game #${gameId}` : "Game"}
</h2> </h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1"> <p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
{phase === "commit" {phase === "commit" && "Commit your move"}
? "Commit your move" {phase === "reveal" && "Reveal your move"}
: "Reveal your move"} {phase === "withdrawCommit" && "Choose which move to withdraw"}
{phase === "withdrawReveal" && "Reveal your withdrawal choice"}
</p> </p>
</div> </div>
<button <button
@@ -266,6 +367,15 @@ export default function GameModal({
secret={secret} secret={secret}
setSecret={saveSecret} setSecret={saveSecret}
savePlayMove={savePlayMove} savePlayMove={savePlayMove}
// MinusOne mode props
selectedMove1={selectedMove1}
setSelectedMove1={saveMoveSelection1}
selectedMove2={selectedMove2}
setSelectedMove2={saveMoveSelection2}
secret1={secret1}
setSecret1={saveSecret1}
secret2={secret2}
setSecret2={saveSecret2}
/> />
)} )}
{phase === "reveal" && ( {phase === "reveal" && (
@@ -279,6 +389,42 @@ export default function GameModal({
secret={secret} secret={secret}
gameDetails={gameDetails} gameDetails={gameDetails}
whoAmI={whoAmI} whoAmI={whoAmI}
// MinusOne mode props
selectedMove1={selectedMove1}
selectedMove2={selectedMove2}
secret1={secret1}
secret2={secret2}
/>
)}
{phase === "withdrawCommit" && (
<Commit
account={account}
contract={contract}
config={config}
web3={web3}
whoAmI={whoAmI}
gameDetails={gameDetails}
setStatus={setStatus}
selectedMove={withdrawChoice}
setSelectedMove={saveWithdrawChoice}
secret={withdrawSecret}
setSecret={saveWithdrawSecret}
savePlayMove={() => {}}
isWithdrawPhase={true}
/>
)}
{phase === "withdrawReveal" && (
<Reveal
account={account}
contract={contract}
config={config}
web3={web3}
setStatus={setStatus}
selectedMove={withdrawChoice}
secret={withdrawSecret}
gameDetails={gameDetails}
whoAmI={whoAmI}
isWithdrawPhase={true}
/> />
)} )}
</div> </div>

View File

@@ -14,6 +14,12 @@ interface RevealProps {
secret: string; secret: string;
gameDetails: GameDetails | null; gameDetails: GameDetails | null;
whoAmI: "player1" | "player2" | ""; whoAmI: "player1" | "player2" | "";
// MinusOne mode props
selectedMove1?: string | null;
selectedMove2?: string | null;
secret1?: string;
secret2?: string;
isWithdrawPhase?: boolean;
} }
type MoveName = "Rock" | "Paper" | "Scissors"; type MoveName = "Rock" | "Paper" | "Scissors";
@@ -42,6 +48,11 @@ export default function Reveal({
secret, secret,
gameDetails, gameDetails,
whoAmI, whoAmI,
selectedMove1,
selectedMove2,
secret1,
secret2,
isWithdrawPhase = false,
}: Readonly<RevealProps>) { }: Readonly<RevealProps>) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selfRevealed, setSelfRevealed] = useState(false); const [selfRevealed, setSelfRevealed] = useState(false);
@@ -51,30 +62,76 @@ export default function Reveal({
const [revealTimeLeft, setRevealTimeLeft] = useState<number>(0); const [revealTimeLeft, setRevealTimeLeft] = useState<number>(0);
const [timeoutExpired, setTimeoutExpired] = useState(false); const [timeoutExpired, setTimeoutExpired] = useState(false);
const isMinusOne = gameDetails?.gameMode === "minusone";
// Generate clear text for reveal
const clearMove = selectedMove && secret ? `${selectedMove}-${secret}` : ""; const clearMove = selectedMove && secret ? `${selectedMove}-${secret}` : "";
const clearMove1 = selectedMove1 && secret1 ? `${selectedMove1}-${secret1}` : "";
const clearMove2 = selectedMove2 && secret2 ? `${selectedMove2}-${secret2}` : "";
// Check game status on mount // Check game status on mount
useEffect(() => { useEffect(() => {
const setStateFromGameDetails = () => { const setStateFromGameDetails = () => {
if (!gameDetails) return; if (!gameDetails) return;
const playerARevealed = Number(gameDetails.playerA.move) !== 0;
const playerBRevealed = Number(gameDetails.playerB.move) !== 0;
setSelfRevealed( if (isMinusOne && !isWithdrawPhase) {
(whoAmI === "player1" && playerARevealed) || // MinusOne initial reveal: check move1
(whoAmI === "player2" && playerBRevealed) const playerARevealed = !!(gameDetails.playerA.move1 && Number(gameDetails.playerA.move1) !== 0);
); const playerBRevealed = !!(gameDetails.playerB.move1 && Number(gameDetails.playerB.move1) !== 0);
setOpponentRevealed(
(whoAmI === "player1" && playerBRevealed) || setSelfRevealed(
(whoAmI === "player2" && playerARevealed) (whoAmI === "player1" && playerARevealed) ||
); (whoAmI === "player2" && playerBRevealed)
setBothRevealed(playerARevealed && playerBRevealed); );
if(bothRevealed){ setOpponentRevealed(
if(Number(gameDetails.outcome) === 1 && whoAmI === "player1") setOutcome(1); (whoAmI === "player1" && playerBRevealed) ||
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player2") setOutcome(1); (whoAmI === "player2" && playerARevealed)
else if(Number(gameDetails.outcome) === 1 && whoAmI === "player2") setOutcome(2); );
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player1") setOutcome(2); setBothRevealed(playerARevealed && playerBRevealed);
else setOutcome(3); } else if (isMinusOne && isWithdrawPhase) {
// MinusOne withdrawal reveal: check withdrawn field
const playerARevealed = !!(gameDetails.playerA.withdrawn && Number(gameDetails.playerA.withdrawn) !== 0);
const playerBRevealed = !!(gameDetails.playerB.withdrawn && Number(gameDetails.playerB.withdrawn) !== 0);
setSelfRevealed(
(whoAmI === "player1" && playerARevealed) ||
(whoAmI === "player2" && playerBRevealed)
);
setOpponentRevealed(
(whoAmI === "player1" && playerBRevealed) ||
(whoAmI === "player2" && playerARevealed)
);
setBothRevealed(playerARevealed && playerBRevealed);
// Set outcome when both revealed withdrawal
if(bothRevealed){
if(Number(gameDetails.outcome) === 1 && whoAmI === "player1") setOutcome(1);
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player2") setOutcome(1);
else if(Number(gameDetails.outcome) === 1 && whoAmI === "player2") setOutcome(2);
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player1") setOutcome(2);
else if(Number(gameDetails.outcome) === 3) setOutcome(3);
}
} else {
// Classic mode: check move field
const playerARevealed = !!(gameDetails.playerA.move && Number(gameDetails.playerA.move) !== 0);
const playerBRevealed = !!(gameDetails.playerB.move && Number(gameDetails.playerB.move) !== 0);
setSelfRevealed(
(whoAmI === "player1" && playerARevealed) ||
(whoAmI === "player2" && playerBRevealed)
);
setOpponentRevealed(
(whoAmI === "player1" && playerBRevealed) ||
(whoAmI === "player2" && playerARevealed)
);
setBothRevealed(playerARevealed && playerBRevealed);
if(bothRevealed){
if(Number(gameDetails.outcome) === 1 && whoAmI === "player1") setOutcome(1);
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player2") setOutcome(1);
else if(Number(gameDetails.outcome) === 1 && whoAmI === "player2") setOutcome(2);
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player1") setOutcome(2);
else setOutcome(3);
}
} }
}; };
@@ -84,7 +141,14 @@ export default function Reveal({
const checkRevealTimeout = async () => { const checkRevealTimeout = async () => {
if (!contract || !gameDetails) return; if (!contract || !gameDetails) return;
try { try {
const timeLeft = await contract.methods.revealTimeLeft(gameDetails.returnGameId).call(); let timeLeft;
if (isMinusOne) {
// MinusOne uses getTimeLeft() for all phases
timeLeft = await contract.methods.getTimeLeft(gameDetails.returnGameId).call();
} else {
// Classic uses revealTimeLeft()
timeLeft = await contract.methods.revealTimeLeft(gameDetails.returnGameId).call();
}
setRevealTimeLeft(Number(timeLeft)); setRevealTimeLeft(Number(timeLeft));
if (Number(timeLeft) <= 0) { if (Number(timeLeft) <= 0) {
setTimeoutExpired(true); setTimeoutExpired(true);
@@ -101,10 +165,38 @@ export default function Reveal({
}, [gameDetails, contract, account, whoAmI]); }, [gameDetails, contract, account, whoAmI]);
const handleReveal = async () => { const handleReveal = async () => {
if (!contract || !web3 || !account || !clearMove) return; if (!contract || !web3 || !account) return;
setLoading(true); setLoading(true);
try { try {
const tx = contract.methods.reveal(gameDetails?.returnGameId, clearMove); let tx;
if (isMinusOne && !isWithdrawPhase) {
// MinusOne initial reveal: revealInitialMoves(gameId, clear1, clear2)
if (!clearMove1 || !clearMove2) {
showErrorToast("Please provide both cleartext moves");
setLoading(false);
return;
}
tx = contract.methods.revealInitialMoves(gameDetails?.returnGameId, clearMove1, clearMove2);
} else if (isMinusOne && isWithdrawPhase) {
// MinusOne withdrawal reveal: withdrawMove(gameId, clear)
if (!clearMove) {
showErrorToast("Please provide cleartext withdrawal choice");
setLoading(false);
return;
}
tx = contract.methods.withdrawMove(gameDetails?.returnGameId, clearMove);
} else {
// Classic mode: reveal(gameId, clear)
if (!clearMove) {
showErrorToast("Please provide cleartext move");
setLoading(false);
return;
}
tx = contract.methods.reveal(gameDetails?.returnGameId, clearMove);
}
const gas = await tx.estimateGas({ from: account }); const gas = await tx.estimateGas({ from: account });
const result = await (globalThis as any).ethereum.request({ const result = await (globalThis as any).ethereum.request({
method: "eth_sendTransaction", method: "eth_sendTransaction",
@@ -244,29 +336,66 @@ export default function Reveal({
{!bothRevealed && !timeoutExpired && ( {!bothRevealed && !timeoutExpired && (
<div className="border p-6 rounded-lg bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-700 dark:to-slate-800"> <div className="border p-6 rounded-lg bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-700 dark:to-slate-800">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white"> <h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white">
Your Move {isWithdrawPhase ? "Your Withdrawal Choice" : isMinusOne ? "Your Moves" : "Your Move"}
</h2> </h2>
{selectedMove ? ( {isMinusOne && !isWithdrawPhase && selectedMove1 && selectedMove2 ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-4 p-3 bg-blue-50 dark:bg-blue-900 rounded-lg">
<div className="flex flex-col items-center">
<span className="text-xs text-slate-500 dark:text-slate-400 mb-1">Move 1</span>
<span className="text-5xl mb-2">{MOVES[selectedMove1].icon}</span>
<span className="font-semibold text-sm">{MOVES[selectedMove1].name}</span>
</div>
<div className="text-2xl text-slate-400"></div>
<div className="bg-white dark:bg-slate-600 p-3 rounded-lg flex-1">
<p className="text-xs text-slate-600 dark:text-slate-300 mb-1">Clear Text 1:</p>
<code className="text-xs font-mono text-slate-700 dark:text-slate-200 break-all">
{clearMove1}
</code>
</div>
</div>
<div className="flex items-center justify-center gap-4 p-3 bg-green-50 dark:bg-green-900 rounded-lg">
<div className="flex flex-col items-center">
<span className="text-xs text-slate-500 dark:text-slate-400 mb-1">Move 2</span>
<span className="text-5xl mb-2">{MOVES[selectedMove2].icon}</span>
<span className="font-semibold text-sm">{MOVES[selectedMove2].name}</span>
</div>
<div className="text-2xl text-slate-400"></div>
<div className="bg-white dark:bg-slate-600 p-3 rounded-lg flex-1">
<p className="text-xs text-slate-600 dark:text-slate-300 mb-1">Clear Text 2:</p>
<code className="text-xs font-mono text-slate-700 dark:text-slate-200 break-all">
{clearMove2}
</code>
</div>
</div>
</div>
) : isWithdrawPhase && selectedMove ? (
<div className="flex items-center justify-center gap-4"> <div className="flex items-center justify-center gap-4">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-6xl mb-2">{MOVES[selectedMove].icon}</span> <span className="text-6xl mb-2">{selectedMove === "1" ? "1⃣" : "2⃣"}</span>
<span className="font-semibold text-lg"> <span className="font-semibold text-lg">Withdraw Move {selectedMove}</span>
{MOVES[selectedMove].name}
</span>
</div> </div>
<div className="text-3xl text-slate-400"></div> <div className="text-3xl text-slate-400"></div>
<div className="bg-white dark:bg-slate-600 p-4 rounded-lg"> <div className="bg-white dark:bg-slate-600 p-4 rounded-lg">
<p className="text-xs text-slate-600 dark:text-slate-300 mb-1"> <p className="text-xs text-slate-600 dark:text-slate-300 mb-1">Clear Text:</p>
Clear Move: <code className="text-sm font-mono text-slate-700 dark:text-slate-200">{clearMove}</code>
</p> </div>
<code className="text-sm font-mono text-slate-700 dark:text-slate-200"> </div>
{clearMove} ) : selectedMove ? (
</code> <div className="flex items-center justify-center gap-4">
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">{MOVES[selectedMove].icon}</span>
<span className="font-semibold text-lg">{MOVES[selectedMove].name}</span>
</div>
<div className="text-3xl text-slate-400"></div>
<div className="bg-white dark:bg-slate-600 p-4 rounded-lg">
<p className="text-xs text-slate-600 dark:text-slate-300 mb-1">Clear Move:</p>
<code className="text-sm font-mono text-slate-700 dark:text-slate-200">{clearMove}</code>
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-center text-slate-600 dark:text-slate-400"> <p className="text-center text-slate-600 dark:text-slate-400">
No move selected yet No {isWithdrawPhase ? "withdrawal choice" : "move"} selected yet
</p> </p>
)} )}
</div> </div>
@@ -349,19 +478,22 @@ export default function Reveal({
{!bothRevealed && !timeoutExpired && ( {!bothRevealed && !timeoutExpired && (
<div className="border-2 border-blue-300 dark:border-blue-600 p-6 rounded-lg bg-blue-50 dark:bg-slate-700"> <div className="border-2 border-blue-300 dark:border-blue-600 p-6 rounded-lg bg-blue-50 dark:bg-slate-700">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white"> <h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white">
Reveal Your Move {isWithdrawPhase ? "Reveal Your Withdrawal" : isMinusOne ? "Reveal Your Moves" : "Reveal Your Move"}
</h2> </h2>
<p className="text-sm text-slate-600 dark:text-slate-300 mb-4"> <p className="text-sm text-slate-600 dark:text-slate-300 mb-4">
Submit your clear move and secret to the blockchain. This proves you {isWithdrawPhase
didn't cheat! ? "Submit your clear withdrawal choice and secret to the blockchain."
: isMinusOne
? "Submit your clear moves and secrets to the blockchain. This proves you didn't cheat!"
: "Submit your clear move and secret to the blockchain. This proves you didn't cheat!"}
</p> </p>
<Button <Button
onClick={handleReveal} onClick={handleReveal}
disabled={loading || !account || !contract || !clearMove || selfRevealed} disabled={loading || !account || !contract || (!clearMove && (!clearMove1 || !clearMove2)) || selfRevealed}
variant="primary" variant="primary"
className="w-full py-3 text-lg" className="w-full py-3 text-lg"
> >
{loading ? "Submitting..." : selfRevealed ? "✅ Revealed" : "Reveal Move"} {loading ? "Submitting..." : selfRevealed ? "✅ Revealed" : isWithdrawPhase ? "Reveal Withdrawal" : "Reveal Move"}
</Button> </Button>
</div> </div>
)} )}
@@ -372,42 +504,133 @@ export default function Reveal({
{/* Moves Comparison */} {/* Moves Comparison */}
<div className="border-2 border-slate-300 dark:border-slate-600 p-6 rounded-lg bg-slate-50 dark:bg-slate-800"> <div className="border-2 border-slate-300 dark:border-slate-600 p-6 rounded-lg bg-slate-50 dark:bg-slate-800">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white text-center"> <h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white text-center">
Final Moves {isMinusOne ? "Final Moves (After Withdrawal)" : "Final Moves"}
</h2> </h2>
<div className="flex items-center justify-center gap-8">
{/* Your Move (always on left) */}
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.icon}
</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">
You
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.name}
</span>
</div>
{/* VS */} {isMinusOne && gameDetails ? (
<div className="flex flex-col items-center"> <div className="space-y-6">
<span className="text-4xl text-slate-400 dark:text-slate-500"> {/* Show all moves including withdrawn ones for MinusOne */}
VS <div className="flex items-center justify-center gap-8">
</span> {/* Your Moves */}
</div> <div className="flex flex-col items-center space-y-2">
<span className="font-semibold text-slate-700 dark:text-slate-300 mb-2">You</span>
<div className="flex gap-2">
<div className={`flex flex-col items-center p-2 rounded ${
(whoAmI === "player1" ? gameDetails.playerA.withdrawn : gameDetails.playerB.withdrawn) === 1
? "opacity-40 bg-red-100 dark:bg-red-900"
: "bg-green-100 dark:bg-green-900"
}`}>
<span className="text-xs text-slate-500 dark:text-slate-400">Move 1</span>
<span className="text-3xl">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move1 : gameDetails.playerB.move1)]?.icon}
</span>
<span className="text-xs">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move1 : gameDetails.playerB.move1)]?.name}
</span>
{(whoAmI === "player1" ? gameDetails.playerA.withdrawn : gameDetails.playerB.withdrawn) === 1 && (
<span className="text-xs text-red-600 dark:text-red-400">Withdrawn</span>
)}
</div>
<div className={`flex flex-col items-center p-2 rounded ${
(whoAmI === "player1" ? gameDetails.playerA.withdrawn : gameDetails.playerB.withdrawn) === 2
? "opacity-40 bg-red-100 dark:bg-red-900"
: "bg-green-100 dark:bg-green-900"
}`}>
<span className="text-xs text-slate-500 dark:text-slate-400">Move 2</span>
<span className="text-3xl">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move2 : gameDetails.playerB.move2)]?.icon}
</span>
<span className="text-xs">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move2 : gameDetails.playerB.move2)]?.name}
</span>
{(whoAmI === "player1" ? gameDetails.playerA.withdrawn : gameDetails.playerB.withdrawn) === 2 && (
<span className="text-xs text-red-600 dark:text-red-400">Withdrawn</span>
)}
</div>
</div>
</div>
{/* Opponent Move (always on right) */} {/* VS */}
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-6xl mb-2"> <span className="text-4xl text-slate-400 dark:text-slate-500">VS</span>
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.icon} </div>
</span>
<span className="font-semibold text-slate-700 dark:text-slate-300"> {/* Opponent Moves */}
Opponent <div className="flex flex-col items-center space-y-2">
</span> <span className="font-semibold text-slate-700 dark:text-slate-300 mb-2">Opponent</span>
<span className="text-sm text-slate-500 dark:text-slate-400"> <div className="flex gap-2">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.name} <div className={`flex flex-col items-center p-2 rounded ${
</span> (whoAmI === "player1" ? gameDetails.playerB.withdrawn : gameDetails.playerA.withdrawn) === 1
? "opacity-40 bg-red-100 dark:bg-red-900"
: "bg-green-100 dark:bg-green-900"
}`}>
<span className="text-xs text-slate-500 dark:text-slate-400">Move 1</span>
<span className="text-3xl">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move1 : gameDetails.playerA.move1)]?.icon}
</span>
<span className="text-xs">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move1 : gameDetails.playerA.move1)]?.name}
</span>
{(whoAmI === "player1" ? gameDetails.playerB.withdrawn : gameDetails.playerA.withdrawn) === 1 && (
<span className="text-xs text-red-600 dark:text-red-400">Withdrawn</span>
)}
</div>
<div className={`flex flex-col items-center p-2 rounded ${
(whoAmI === "player1" ? gameDetails.playerB.withdrawn : gameDetails.playerA.withdrawn) === 2
? "opacity-40 bg-red-100 dark:bg-red-900"
: "bg-green-100 dark:bg-green-900"
}`}>
<span className="text-xs text-slate-500 dark:text-slate-400">Move 2</span>
<span className="text-3xl">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move2 : gameDetails.playerA.move2)]?.icon}
</span>
<span className="text-xs">
{MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move2 : gameDetails.playerA.move2)]?.name}
</span>
{(whoAmI === "player1" ? gameDetails.playerB.withdrawn : gameDetails.playerA.withdrawn) === 2 && (
<span className="text-xs text-red-600 dark:text-red-400">Withdrawn</span>
)}
</div>
</div>
</div>
</div>
</div> </div>
</div> ) : (
<div className="flex items-center justify-center gap-8">
{/* Classic game - Your Move (always on left) */}
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.icon}
</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">
You
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.name}
</span>
</div>
{/* VS */}
<div className="flex flex-col items-center">
<span className="text-4xl text-slate-400 dark:text-slate-500">
VS
</span>
</div>
{/* Classic game - Opponent Move (always on right) */}
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.icon}
</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">
Opponent
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.name}
</span>
</div>
</div>
)}
</div> </div>
{/* Outcome Section */} {/* Outcome Section */}

View File

@@ -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 <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