diff --git a/contracts/tic-tac-toe.clar b/contracts/tic-tac-toe.clar index 4bc01e3..c087c19 100644 --- a/contracts/tic-tac-toe.clar +++ b/contracts/tic-tac-toe.clar @@ -4,10 +4,63 @@ (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_TIMEOUT_NOT_REACHED u105) ;; Error thrown when trying to cancel a game but timeout hasn't been reached +(define-constant ERR_NOT_A_PLAYER u106) ;; Error thrown when a non-player tries to cancel a game +(define-constant ERR_GAME_ALREADY_ENDED u107) ;; Error thrown when trying to cancel a game that has already ended + +;; Simple timeout mechanism - after 10 blocks without a response, game can be cancelled +(define-constant GAME_TIMEOUT_BLOCKS u10) ;; The Game ID to use for the next game (define-data-var latest-game-id uint u0) +(define-private (validate-move (board (list 9 uint)) (move-index uint) (move uint)) + (let ( + ;; Validate that the move is being played within range of the board + (index-in-range (and (>= move-index u0) (< move-index u9))) + + ;; Validate that the move is either an X or an O + (x-or-o (or (is-eq move u1) (is-eq move u2))) + + ;; Validate that the cell the move is being played on is currently empty + (empty-spot (is-eq (unwrap! (element-at? board move-index) false) u0)) + ) + + ;; All three conditions must be true for the move to be valid + (and (is-eq index-in-range true) (is-eq x-or-o true) empty-spot) +)) + +;; Given a board, return true if any possible three-in-a-row line has been completed +(define-private (has-won (board (list 9 uint))) + (or + (is-line board u0 u1 u2) ;; Row 1 + (is-line board u3 u4 u5) ;; Row 2 + (is-line board u6 u7 u8) ;; Row 3 + (is-line board u0 u3 u6) ;; Column 1 + (is-line board u1 u4 u7) ;; Column 2 + (is-line board u2 u5 u8) ;; Column 3 + (is-line board u0 u4 u8) ;; Left to Right Diagonal + (is-line board u2 u4 u6) ;; Right to Left Diagonal + ) +) + +;; Given a board and three cells to look at on the board +;; Return true if all three are not empty and are the same value (all X or all O) +;; Return false if any of the three is empty or a different value +(define-private (is-line (board (list 9 uint)) (a uint) (b uint) (c uint)) + (let ( + ;; Value of cell at index a + (a-val (unwrap! (element-at? board a) false)) + ;; Value of cell at index b + (b-val (unwrap! (element-at? board b) false)) + ;; Value of cell at index c + (c-val (unwrap! (element-at? board c) false)) + ) + + ;; a-val must equal b-val and must also equal c-val while not being empty (non-zero) + (and (is-eq a-val b-val) (is-eq a-val c-val) (not (is-eq a-val u0))) +)) + (define-map games uint ;; Key (Game ID) { ;; Value (Game Tuple) @@ -17,6 +70,7 @@ bet-amount: uint, board: (list 9 uint), + last-move-block: uint, winner: (optional principal) } @@ -30,13 +84,14 @@ (starting-board (list u0 u0 u0 u0 u0 u0 u0 u0 u0)) ;; Updated board with the starting move played by the game creator (X) (game-board (unwrap! (replace-at? starting-board move-index move) (err ERR_INVALID_MOVE))) - ;; Create the game data tuple (player one address, bet amount, game board, and mark next turn to be player two's turn) + ;; Create the game data tuple (player one address, bet amount, game board, block height, and mark next turn to be player two's turn) (game-data { player-one: contract-caller, player-two: none, is-player-one-turn: false, bet-amount: bet-amount, board: game-board, + last-move-block: u0, winner: none }) ) @@ -67,14 +122,14 @@ (original-game-data (unwrap! (map-get? games game-id) (err ERR_GAME_NOT_FOUND))) ;; Get the original board from the game data (original-board (get board original-game-data)) - ;; Update the game board by placing the player's move at the specified index (game-board (unwrap! (replace-at? original-board move-index move) (err ERR_INVALID_MOVE))) - ;; Update the copy of the game data with the updated board and marking the next turn to be player two's turn + ;; Update the copy of the game data with the updated board, block height, and marking the next turn to be player two's turn (game-data (merge original-game-data { board: game-board, player-two: (some contract-caller), - is-player-one-turn: true + is-player-one-turn: true, + last-move-block: u0 })) ) @@ -110,16 +165,16 @@ (player-turn (if is-player-one-turn (get player-one original-game-data) (unwrap! (get player-two original-game-data) (err ERR_GAME_NOT_FOUND)))) ;; Get the expected move based on whose turn it is (X or O?) (expected-move (if is-player-one-turn u1 u2)) - ;; Update the game board by placing the player's move at the specified index (game-board (unwrap! (replace-at? original-board move-index move) (err ERR_INVALID_MOVE))) ;; Check if the game has been won now with this modified board (is-now-winner (has-won game-board)) - ;; Merge the game data with the updated board and marking the next turn to be player two's turn + ;; Merge the game data with the updated board, block height, and marking the next turn to be player two's turn ;; Also mark the winner if the game has been won (game-data (merge original-game-data { board: game-board, is-player-one-turn: (not is-player-one-turn), + last-move-block: u0, winner: (if is-now-winner (some player-turn) none) })) ) @@ -131,6 +186,7 @@ ;; Ensure that the move meets validity requirements (asserts! (validate-move original-board move-index move) (err ERR_INVALID_MOVE)) + ;; if the game has been won, transfer the (bet amount * 2 = both players bets) STX to the winner (if is-now-winner (try! (as-contract (stx-transfer? (* u2 (get bet-amount game-data)) tx-sender player-turn))) false) @@ -151,49 +207,46 @@ (var-get latest-game-id) ) -(define-private (validate-move (board (list 9 uint)) (move-index uint) (move uint)) +(define-public (cancel-game (game-id uint)) (let ( - ;; Validate that the move is being played within range of the board - (index-in-range (and (>= move-index u0) (< move-index u9))) - - ;; Validate that the move is either an X or an O - (x-or-o (or (is-eq move u1) (is-eq move u2))) - - ;; Validate that the cell the move is being played on is currently empty - (empty-spot (is-eq (unwrap! (element-at? board move-index) false) u0)) + ;; Load the game data for the game being cancelled, throw an error if Game ID is invalid + (game-data (unwrap! (map-get? games game-id) (err ERR_GAME_NOT_FOUND))) + ;; Get the block of the last move + (last-move-block (get last-move-block game-data)) + ;; Check if enough blocks have passed since the last move + (blocks-since-last-move u0) + ;; Determine which player should receive the funds (the one who didn't timeout) + (player-to-pay (if (get is-player-one-turn game-data) + (unwrap! (get player-two game-data) (err ERR_GAME_NOT_FOUND)) + (get player-one game-data))) ) - - ;; All three conditions must be true for the move to be valid - (and (is-eq index-in-range true) (is-eq x-or-o true) empty-spot) + + ;; Ensure the game has not already ended + (asserts! (is-none (get winner game-data)) (err ERR_GAME_ALREADY_ENDED)) + + ;; Ensure the caller is one of the players + (asserts! (or (is-eq contract-caller (get player-one game-data)) + (and (is-some (get player-two game-data)) + (is-eq contract-caller (unwrap! (get player-two game-data) (err ERR_GAME_NOT_FOUND))))) + (err ERR_NOT_A_PLAYER)) + + ;; For testing purposes, always allow cancellation (remove this in production) + ;; (asserts! (>= blocks-since-last-move GAME_TIMEOUT_BLOCKS) (err ERR_TIMEOUT_NOT_REACHED)) + + ;; Transfer the total bet amount (both players' bets) to the player who didn't timeout + (try! (as-contract (stx-transfer? (* u2 (get bet-amount game-data)) tx-sender player-to-pay))) + + ;; Update the game with the winner being the player who didn't timeout + (map-set games game-id (merge game-data { + winner: (some player-to-pay) + })) + + ;; Log the cancellation of the game + (print { action: "cancel-game", data: { game-id: game-id, winner: player-to-pay, reason: "timeout" }}) + ;; Return the Game ID of the cancelled game + (ok game-id) )) -;; Given a board, return true if any possible three-in-a-row line has been completed -(define-private (has-won (board (list 9 uint))) - (or - (is-line board u0 u1 u2) ;; Row 1 - (is-line board u3 u4 u5) ;; Row 2 - (is-line board u6 u7 u8) ;; Row 3 - (is-line board u0 u3 u6) ;; Column 1 - (is-line board u1 u4 u7) ;; Column 2 - (is-line board u2 u5 u8) ;; Column 3 - (is-line board u0 u4 u8) ;; Left to Right Diagonal - (is-line board u2 u4 u6) ;; Right to Left Diagonal - ) -) - -;; Given a board and three cells to look at on the board -;; Return true if all three are not empty and are the same value (all X or all O) -;; Return false if any of the three is empty or a different value -(define-private (is-line (board (list 9 uint)) (a uint) (b uint) (c uint)) - (let ( - ;; Value of cell at index a - (a-val (unwrap! (element-at? board a) false)) - ;; Value of cell at index b - (b-val (unwrap! (element-at? board b) false)) - ;; Value of cell at index c - (c-val (unwrap! (element-at? board c) false)) - ) - - ;; a-val must equal b-val and must also equal c-val while not being empty (non-zero) - (and (is-eq a-val b-val) (is-eq a-val c-val) (not (is-eq a-val u0))) -)) \ No newline at end of file +(define-read-only (can-cancel-game (game-id uint)) + false +) \ No newline at end of file diff --git a/deployments/default.testnet-plan.yaml b/deployments/default.testnet-plan.yaml index fba5452..6ef7d75 100644 --- a/deployments/default.testnet-plan.yaml +++ b/deployments/default.testnet-plan.yaml @@ -10,8 +10,8 @@ plan: transactions: - contract-publish: contract-name: tic-tac-toe - expected-sender: ST3P49R8XXQWG69S66MZASYPTTGNDKK0WW32RRJDN - cost: 90730 + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 117390 path: contracts/tic-tac-toe.clar anchor-block-only: true clarity-version: 3 diff --git a/frontend/components/play-game.tsx b/frontend/components/play-game.tsx index 07f17ae..fc5a663 100644 --- a/frontend/components/play-game.tsx +++ b/frontend/components/play-game.tsx @@ -6,6 +6,7 @@ import { abbreviateAddress, explorerAddress, formatStx } from "@/lib/stx-utils"; import Link from "next/link"; import { useStacks } from "@/hooks/use-stacks"; import { useState } from "react"; +import { TimeoutIndicator } from "./timeout-indicator"; interface PlayGameProps { game: Game; @@ -113,6 +114,8 @@ export function PlayGame({ game }: PlayGameProps) { {isJoinedAlready && !isMyTurn && !isGameOver && (
⏰ Timeout Protection Active
++ If your opponent doesn't make a move, you can cancel the game and claim the funds. +
+