Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions contracts/tic-tac-toe.clar
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
(define-constant ERR_GAME_NOT_FOUND u102) ;; Error thrown when a game cannot be found given a Game ID, i.e. invalid Game ID
(define-constant ERR_GAME_CANNOT_BE_JOINED u103) ;; Error thrown when a game cannot be joined, usually because it already has two players
(define-constant ERR_NOT_YOUR_TURN u104) ;; Error thrown when a player tries to make a move when it is not their turn
(define-constant ERR_UNDO_REQUEST_PENDING u105) ;; Error thrown when an undo is requested but one is already pending
(define-constant ERR_NO_UNDO_REQUEST_PENDING u106) ;; Error thrown when an undo is approved but none is pending
(define-constant ERR_CANNOT_REQUEST_UNDO u107) ;; Error thrown when a player who did not just move tries to request an undo
(define-constant ERR_CANNOT_APPROVE_UNDO u108) ;; Error thrown when a player tries to approve an undo for the wrong turn
(define-constant ERR_NO_LAST_MOVE u109) ;; Error thrown when approving an undo but there is no last move to revert

;; The Game ID to use for the next game
(define-data-var latest-game-id uint u0)
Expand All @@ -18,7 +23,11 @@
bet-amount: uint,
board: (list 9 uint),

winner: (optional principal)
winner: (optional principal),

;; Co-op Undo
undo-requester: (optional principal),
last-move-position: (optional uint)
}
)

Expand All @@ -37,7 +46,11 @@
is-player-one-turn: false,
bet-amount: bet-amount,
board: game-board,
winner: none
winner: none,

;; Co-op Undo
undo-requester: none,
last-move-position: (some move-index)
})
)

Expand Down Expand Up @@ -74,7 +87,10 @@
(game-data (merge original-game-data {
board: game-board,
player-two: (some contract-caller),
is-player-one-turn: true
is-player-one-turn: true,

;; Co-op Undo
last-move-position: (some move-index)
}))
)

Expand Down Expand Up @@ -120,7 +136,12 @@
(game-data (merge original-game-data {
board: game-board,
is-player-one-turn: (not is-player-one-turn),
winner: (if is-now-winner (some player-turn) none)
winner: (if is-now-winner (some player-turn) none),

;; Co-op Undo
;; A move implicitly denies any pending undo request
undo-requester: none,
last-move-position: (some move-index)
}))
)

Expand All @@ -143,6 +164,67 @@
(ok game-id)
))

(define-public (request-undo (game-id uint))
(let (
(game (unwrap! (map-get? games game-id) (err ERR_GAME_NOT_FOUND)))
(requester tx-sender)
(p1 (get player-one game))
(p2 (unwrap! (get player-two game) (err ERR_GAME_CANNOT_BE_JOINED)))
(turn-is-p1 (get is-player-one-turn game))
)
;; Game must be ongoing
(asserts! (is-none (get winner game)) (err ERR_INVALID_MOVE))
;; No undo request can be currently pending
(asserts! (is-none (get undo-requester game)) (err ERR_UNDO_REQUEST_PENDING))

;; Requester must be the player who just moved.
;; If it's p1's turn now, p2 must be the requester.
(asserts! (if turn-is-p1 (is-eq requester p2) (is-eq requester p1)) (err ERR_CANNOT_REQUEST_UNDO))

(map-set games game-id
(merge game { undo-requester: (some requester) }))
(ok true)
))

(define-public (approve-undo (game-id uint))
(let ((game (unwrap! (map-get? games game-id) (err ERR_GAME_NOT_FOUND))))
(let
(
(approver tx-sender)
(requester (unwrap! (get undo-requester game) (err ERR_NO_UNDO_REQUEST_PENDING)))
(pos-to-undo (unwrap! (get last-move-position game) (err ERR_NO_LAST_MOVE)))
(current-board (get board game))
(turn-is-p1 (get is-player-one-turn game))
)
;; Body of the let
;; Game must be ongoing
(asserts! (is-none (get winner game)) (err ERR_INVALID_MOVE))
;; The approver must be the current player
(asserts! (if turn-is-p1 (is-eq approver (get player-one game)) (is-eq approver (unwrap! (get player-two game) (err ERR_GAME_NOT_FOUND)))) (err ERR_CANNOT_APPROVE_UNDO))
;; The approver cannot be the requester
(asserts! (not (is-eq approver requester)) (err ERR_CANNOT_APPROVE_UNDO))

(let
(
(new-board (unwrap! (replace-at? current-board pos-to-undo u0) (err ERR_INVALID_MOVE)))
)
;; Body of the inner let
(map-set games game-id
(merge game {
board: new-board,
;; Toggle turn back to the requester
is-player-one-turn: (not turn-is-p1),
;; Clear undo state
undo-requester: none,
last-move-position: none
})
)
(ok true)
)
)
)
)

(define-read-only (get-game (game-id uint))
(map-get? games game-id)
)
Expand Down
4 changes: 2 additions & 2 deletions deployments/default.testnet-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ plan:
- id: 0
transactions:
- contract-publish:
contract-name: tic-tac-toe
expected-sender: ST3P49R8XXQWG69S66MZASYPTTGNDKK0WW32RRJDN
contract-name: tic-tac-toe-v2
expected-sender: ST2QK4128H22NH4H8MD2AVP72M0Q72TKS48VB5469
cost: 90730
path: contracts/tic-tac-toe.clar
anchor-block-only: true
Expand Down
92 changes: 41 additions & 51 deletions frontend/components/play-game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,34 @@ interface PlayGameProps {
}

export function PlayGame({ game }: PlayGameProps) {
const { userData, handleJoinGame, handlePlayGame } = useStacks();
const {
userData,
handleJoinGame,
handlePlayGame,
handleRequestUndo,
handleApproveUndo,
} = useStacks();
const [board, setBoard] = useState(game.board);
const [playedMoveIndex, setPlayedMoveIndex] = useState(-1);
if (!userData) return null;

const isPlayerOne =
userData.profile.stxAddress.testnet === game["player-one"];
const isPlayerTwo =
userData.profile.stxAddress.testnet === game["player-two"];
const myAddress = userData.profile.stxAddress.testnet;
const isPlayerOne = myAddress === game["player-one"];
const isPlayerTwo = myAddress === game["player-two"];

const isJoinable = game["player-two"] === null && !isPlayerOne;
const isJoinedAlready = isPlayerOne || isPlayerTwo;
const nextMove = game["is-player-one-turn"] ? Move.X : Move.O;
const isMyTurn =
(game["is-player-one-turn"] && isPlayerOne) ||
(!game["is-player-one-turn"] && isPlayerTwo);
const isMyTurn = (game["is-player-one-turn"] && isPlayerOne) || (!game["is-player-one-turn"] && isPlayerTwo);
const isGameOver = game.winner !== null;

// Undo feature state
const undoRequestedByOpponent = game["undo-requester"] !== null && game["undo-requester"] !== myAddress;
const iJustMoved = !isMyTurn && isJoinedAlready && !isGameOver;
const undoRequestIsPending = game["undo-requester"] !== null;

function onCellClick(index: number) {
if (undoRequestedByOpponent) return; // Don't allow moves if opponent is requesting undo
const tempBoard = [...game.board];
tempBoard[index] = nextMove;
setBoard(tempBoard);
Expand All @@ -47,50 +56,22 @@ export function PlayGame({ game }: PlayGameProps) {
/>

<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500">Bet Amount: </span>
<span>{formatStx(game["bet-amount"])} STX</span>
</div>
{/* Game Info unchanged */}
</div>

<div className="flex items-center justify-between gap-2">
<span className="text-gray-500">Player One: </span>
<Link
href={explorerAddress(game["player-one"])}
target="_blank"
className="hover:underline"
{/* Undo Request UI */}
{undoRequestedByOpponent && !isGameOver && (
<div className="p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 flex flex-col gap-2">
<p className="font-bold">Undo Request</p>
<p>Your opponent wants to undo their last move.</p>
<button
onClick={() => handleApproveUndo(game.id)}
className="bg-yellow-500 text-white px-4 py-2 rounded self-start"
>
{abbreviateAddress(game["player-one"])}
</Link>
Approve Undo
</button>
</div>

<div className="flex items-center justify-between gap-2">
<span className="text-gray-500">Player Two: </span>
{game["player-two"] ? (
<Link
href={explorerAddress(game["player-two"])}
target="_blank"
className="hover:underline"
>
{abbreviateAddress(game["player-two"])}
</Link>
) : (
<span>Nobody</span>
)}
</div>

{game["winner"] && (
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500">Winner: </span>
<Link
href={explorerAddress(game["winner"])}
target="_blank"
className="hover:underline"
>
{abbreviateAddress(game["winner"])}
</Link>
</div>
)}
</div>
)}

{isJoinable && (
<button
Expand All @@ -101,7 +82,7 @@ export function PlayGame({ game }: PlayGameProps) {
</button>
)}

{isMyTurn && (
{isMyTurn && !undoRequestIsPending && (
<button
onClick={() => handlePlayGame(game.id, playedMoveIndex, nextMove)}
className="bg-blue-500 text-white px-4 py-2 rounded"
Expand All @@ -110,7 +91,16 @@ export function PlayGame({ game }: PlayGameProps) {
</button>
)}

{isJoinedAlready && !isMyTurn && !isGameOver && (
{iJustMoved && !undoRequestIsPending && (
<button
onClick={() => handleRequestUndo(game.id)}
className="bg-gray-500 text-white px-4 py-2 rounded"
>
Request Undo
</button>
)}

{isJoinedAlready && !isMyTurn && !isGameOver && !undoRequestIsPending && (
<div className="text-gray-500">Waiting for opponent to play...</div>
)}
</div>
Expand Down
Loading