From a38c2f2089602072213b917561200ddc91da77c5 Mon Sep 17 00:00:00 2001 From: Ashok Menon Date: Wed, 22 May 2024 14:52:41 +0200 Subject: [PATCH] [Examples/Move] Tic-tac-toe ## Description Port Move code for the tic tac toe example into the `sui/examples` directory, modernising it in the process: - Standardise the implementation between the owned, shared and multi-sig variants, so that the only differences are relevant to what they need to do differently. In doing this, the owned and multi-sig variants become identical (at least at the level of Move code). - Use transfer-to-object in the "owned" example, to send potential moves to the `Game` object, instead of the admin that owns the game. - Use more Move 2024 features (`syntax(index)`, receiver function aliases, etc). - Improve test coverage ## Test plan New unit tests: ``` tic_tac_toe/move$ sui move test ``` --- examples/tic-tac-toe/move/Move.toml | 9 + examples/tic-tac-toe/move/sources/owned.move | 326 ++++++++++++++++++ examples/tic-tac-toe/move/sources/shared.move | 248 +++++++++++++ .../tic-tac-toe/move/tests/owned_tests.move | 323 +++++++++++++++++ .../tic-tac-toe/move/tests/shared_tests.move | 321 +++++++++++++++++ 5 files changed, 1227 insertions(+) create mode 100644 examples/tic-tac-toe/move/Move.toml create mode 100644 examples/tic-tac-toe/move/sources/owned.move create mode 100644 examples/tic-tac-toe/move/sources/shared.move create mode 100644 examples/tic-tac-toe/move/tests/owned_tests.move create mode 100644 examples/tic-tac-toe/move/tests/shared_tests.move diff --git a/examples/tic-tac-toe/move/Move.toml b/examples/tic-tac-toe/move/Move.toml new file mode 100644 index 0000000000000..30f33c34a5aab --- /dev/null +++ b/examples/tic-tac-toe/move/Move.toml @@ -0,0 +1,9 @@ +[package] +name = "tic_tac_toe" +edition = "2024.beta" + +[dependencies] +Sui = { local = "../../../crates/sui-framework/packages/sui-framework" } + +[addresses] +tic_tac_toe = "0x0" diff --git a/examples/tic-tac-toe/move/sources/owned.move b/examples/tic-tac-toe/move/sources/owned.move new file mode 100644 index 0000000000000..6562b16aa86f0 --- /dev/null +++ b/examples/tic-tac-toe/move/sources/owned.move @@ -0,0 +1,326 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// An implementation of Tic Tac Toe, using owned objects. +/// +/// The `Game` object is owned by an admin, so players cannot mutate the game +/// board directly. Instead, they convey their intention to place a mark by +/// transferring a `Mark` object to the `Game`. +/// +/// This means that every move takes two owned object fast path operations -- +/// one by the player, and one by the admin. The admin could be a third party +/// running a centralized service that monitors marker placement events and +/// responds to them, or it could be a 1-of-2 multisig address shared between +/// the two players, as demonstrated in the demo app. +/// +/// The `shared` module shows a variant of this game implemented using shared +/// objects, which provides different trade-offs: Using shared objects is more +/// expensive, however the implementation is more straightforward and each move +/// only requires one transaction. +module tic_tac_toe::owned { + use sui::event; + use sui::transfer::Receiving; + + // === Object Types === + + /// The state of an active game of tic-tac-toe. + public struct Game has key, store { + id: UID, + + /// Marks on the board. + board: vector, + + /// The next turn to be played. + turn: u8, + + /// The address expected to send moves on behalf of X. + x: address, + + /// The address expected to send moves on behalf of O. + o: address, + + /// Public key of the admin address. + admin: vector, + } + + /// The player that the next turn is expected from is given a `TurnCap`. + public struct TurnCap has key { + id: UID, + game: ID, + } + + /// A request to make a play -- only the player with the `TurnCap` can + /// create and send `Mark`s. + public struct Mark has key, store { + id: UID, + player: address, + row: u8, + col: u8, + } + + /// An NFT representing a finished game. Sent to the winning player if there + /// is one, or to both players in the case of a draw. + public struct Trophy has key { + id: UID, + + /// Whether the game was won or drawn. + status: u8, + + /// The state of the board at the end of the game. + board: vector, + + /// The number of turns played + turn: u8, + + /// The other player (relative to the player who owns this Trophy). + other: address, + } + + // === Event Types === + + public struct MarkSent has copy, drop { + game: ID, + mark: ID, + } + + public struct GameEnd has copy, drop { + game: ID, + } + + // === Constants === + + // Marks + const MARK__: u8 = 0; + const MARK_X: u8 = 1; + const MARK_O: u8 = 2; + + // Trophy status + const TROPHY_NONE: u8 = 0; + const TROPHY_DRAW: u8 = 1; + const TROPHY_WIN: u8 = 2; + + // === Errors === + + /// Move was for a position that doesn't exist on the board. + const EInvalidLocation: u64 = 0; + + /// Game expected a move from another player. + const EWrongPlayer: u64 = 1; + + /// Game has not reached an end condition. + const ENotFinished: u64 = 2; + + /// Can't place a mark on a finished game. + const EAlreadyFinished: u64 = 3; + + /// Game reached an end state that wasn't expected. + const EInvalidEndState: u64 = 4; + + // === Public Functions === + + /// Create a new game, played by `x` and `o`. The game should be + /// transfered to the address that will administrate the game. If + /// that address is a multi-sig of the two players, its public key + /// should be passed as `admin`. + public fun new(x: address, o: address, admin: vector, ctx: &mut TxContext): Game { + let game = Game { + id: object::new(ctx), + board: vector[ + MARK__, MARK__, MARK__, + MARK__, MARK__, MARK__, + MARK__, MARK__, MARK__, + ], + + turn: 0, + x, + o, + admin, + }; + + let turn = TurnCap { + id: object::new(ctx), + game: object::id(&game), + }; + + // X is the first player, so send the capability to them. + transfer::transfer(turn, x); + game + } + + /// Called by the active player to express their intention to make a move. + /// This consumes the `TurnCap` to prevent a player from making more than + /// one move on their turn. + public fun send_mark(cap: TurnCap, row: u8, col: u8, ctx: &mut TxContext) { + assert!(row < 3 && col < 3, EInvalidLocation); + + let TurnCap { id, game } = cap; + id.delete(); + + let mark = Mark { + id: object::new(ctx), + player: ctx.sender(), + row, + col, + }; + + event::emit(MarkSent { game, mark: object::id(&mark) }); + transfer::transfer(mark, game.to_address()); + } + + /// Called by the admin (who owns the `Game`), to commit a player's + /// intention to make a move. If the game should end, `Trophy`s are sent to + /// the appropriate players, if the game should continue, a new `TurnCap` is + /// sent to the player who should make the next move. + public fun place_mark( + game: &mut Game, + mark: Receiving, + ctx: &mut TxContext, + ) { + assert!(game.ended() == TROPHY_NONE, EAlreadyFinished); + + // Fetch the mark on behalf of the game -- only works if the mark in + // question was sent to this game. + let Mark { id, row, col, player } = transfer::receive(&mut game.id, mark); + id.delete(); + + // Confirm that the mark is from the player we expect -- it should not + // be possible to hit this assertion, because the `Mark`s can only be + // created by the address that owns the `TurnCap` which cannot be + // transferred, and is always held by `game.next_player()`. + let (me, them, sentinel) = game.next_player(); + assert!(me == player, EWrongPlayer); + + if (game[row, col] == MARK__) { + *(&mut game[row, col]) = sentinel; + game.turn = game.turn + 1; + }; + + // Check win condition -- if there is a winner, send them the trophy, + // otherwise, create a new turn cap and send that to the next player. + let end = game.ended(); + if (end == TROPHY_WIN) { + transfer::transfer(game.mint_trophy(end, them, ctx), me); + event::emit(GameEnd { game: object::id(game) }); + } else if (end == TROPHY_DRAW) { + transfer::transfer(game.mint_trophy(end, them, ctx), me); + transfer::transfer(game.mint_trophy(end, me, ctx), them); + event::emit(GameEnd { game: object::id(game) }); + } else if (end == TROPHY_NONE) { + let cap = TurnCap { id: object::new(ctx), game: object::id(game) }; + let (to, _, _) = game.next_player(); + transfer::transfer(cap, to); + } else { + abort EInvalidEndState + } + } + + public fun burn(game: Game) { + assert!(game.ended() != TROPHY_NONE, ENotFinished); + let Game { id, .. } = game; + id.delete(); + } + + /// Test whether the game has reached an end condition or not. + public fun ended(game: &Game): u8 { + if ( + // Test rows + test_triple(game, 0, 1, 2) || + test_triple(game, 3, 4, 5) || + test_triple(game, 6, 7, 8) || + // Test columns + test_triple(game, 0, 3, 6) || + test_triple(game, 1, 4, 7) || + test_triple(game, 2, 5, 8) || + // Test diagonals + test_triple(game, 0, 4, 8) || + test_triple(game, 2, 4, 6) + ) { + TROPHY_WIN + } else if (game.turn == 9) { + TROPHY_DRAW + } else { + TROPHY_NONE + } + } + + #[syntax(index)] + public fun mark(game: &Game, row: u8, col: u8): &u8 { + &game.board[(row * 3 + col) as u64] + } + + #[syntax(index)] + fun mark_mut(game: &mut Game, row: u8, col: u8): &mut u8 { + &mut game.board[(row * 3 + col) as u64] + } + + // === Private Helpers === + + /// Address of the player the move is expected from, the address of the + /// other player, and the mark to use for the upcoming move. + fun next_player(game: &Game): (address, address, u8) { + if (game.turn % 2 == 0) { + (game.x, game.o, MARK_X) + } else { + (game.o, game.x, MARK_O) + } + } + + /// Test whether the values at the triple of positions all match each other + /// (and are not all EMPTY). + fun test_triple(game: &Game, x: u8, y: u8, z: u8): bool { + let x = game.board[x as u64]; + let y = game.board[y as u64]; + let z = game.board[z as u64]; + + MARK__ != x && x == y && y == z + } + + /// Create a trophy from the current state of the `game`, that indicates + /// that a player won or drew against `other` player. + fun mint_trophy( + game: &Game, + status: u8, + other: address, + ctx: &mut TxContext, + ): Trophy { + Trophy { + id: object::new(ctx), + status, + board: game.board, + turn: game.turn, + other, + } + } + + // === Test Helpers === + #[test_only] public use fun game_board as Game.board; + #[test_only] public use fun trophy_status as Trophy.status; + #[test_only] public use fun trophy_board as Trophy.board; + #[test_only] public use fun trophy_turn as Trophy.turn; + #[test_only] public use fun trophy_other as Trophy.other; + + #[test_only] + public fun game_board(game: &Game): vector { + game.board + } + + #[test_only] + public fun trophy_status(trophy: &Trophy): u8 { + trophy.status + } + + #[test_only] + public fun trophy_board(trophy: &Trophy): vector { + trophy.board + } + + #[test_only] + public fun trophy_turn(trophy: &Trophy): u8 { + trophy.turn + } + + #[test_only] + public fun trophy_other(trophy: &Trophy): address { + trophy.other + } +} diff --git a/examples/tic-tac-toe/move/sources/shared.move b/examples/tic-tac-toe/move/sources/shared.move new file mode 100644 index 0000000000000..22e5efd60013f --- /dev/null +++ b/examples/tic-tac-toe/move/sources/shared.move @@ -0,0 +1,248 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// An implementation of Tic Tac Toe, using shared objects. +/// +/// The `Game` object is shared so both players can mutate it, and +/// contains authorization logic to only accept a move from the +/// correct player. +/// +/// The `owned` module shows a variant of this game implemented using +/// only fast path transactions, which should be cheaper and lower +/// latency, but either requires a centralized service or a multi-sig +/// set-up to own the game. +module tic_tac_toe::shared { + /// The state of an active game of tic-tac-toe. + public struct Game has key { + id: UID, + + /// Marks on the board. + board: vector, + + /// The next turn to be played. + turn: u8, + + /// The address expected to send moves on behalf of X. + x: address, + + /// The address expected to send moves on behalf of O. + o: address, + } + + /// An NFT representing a finished game. Sent to the winning player if there + /// is one, or to both players in the case of a draw. + public struct Trophy has key { + id: UID, + + /// Whether the game was won or drawn. + status: u8, + + /// The state of the board at the end of the game. + board: vector, + + /// The number of turns played + turn: u8, + + /// The other player (relative to the player who owns this Trophy). + other: address, + } + + // === Constants === + + // Marks + const MARK__: u8 = 0; + const MARK_X: u8 = 1; + const MARK_O: u8 = 2; + + // Trophy status + const TROPHY_NONE: u8 = 0; + const TROPHY_DRAW: u8 = 1; + const TROPHY_WIN: u8 = 2; + + // === Errors === + + /// Move was for a position that doesn't exist on the board. + const EInvalidLocation: u64 = 0; + + /// Game expected a move from another player. + const EWrongPlayer: u64 = 1; + + /// Attempt to place mark on a filled slot. + const EAlreadyFilled: u64 = 2; + + /// Game has not reached an end condition. + const ENotFinished: u64 = 3; + + /// Can't place a mark on a finished game. + const EAlreadyFinished: u64 = 4; + + /// Game reached an end state that wasn't expected. + const EInvalidEndState: u64 = 5; + + // === Public Functions === + + /// Create a new game, played by `x` and `o`. This function should be called + /// by the address responsible for administrating the game. + public fun new(x: address, o: address, ctx: &mut TxContext) { + transfer::share_object(Game { + id: object::new(ctx), + board: vector[ + MARK__, MARK__, MARK__, + MARK__, MARK__, MARK__, + MARK__, MARK__, MARK__, + ], + + turn: 0, + x, + o, + }); + } + + /// Called by the next player to add a new mark. + public fun place_mark( + game: &mut Game, + row: u8, + col: u8, + ctx: &mut TxContext, + ) { + assert!(game.ended() == TROPHY_NONE, EAlreadyFinished); + assert!(row < 3 && col < 3, EInvalidLocation); + + // Confirm that the mark is from the player we expect -- it should not + // be possible to hit this assertion, because the `Mark`s can only be + // created by the address that owns the `TurnCap` which cannot be + // transferred, and is always held by `game.next_player()`. + let (me, them, sentinel) = game.next_player(); + assert!(me == ctx.sender(), EWrongPlayer); + + if (game[row, col] != MARK__) { + abort EAlreadyFilled + }; + + *(&mut game[row, col]) = sentinel; + game.turn = game.turn + 1; + + // Check win condition -- if there is a winner, send them the trophy, + // otherwise, create a new turn cap and send that to the next player. + let end = game.ended(); + if (end == TROPHY_WIN) { + transfer::transfer(game.mint_trophy(end, them, ctx), me); + } else if (end == TROPHY_DRAW) { + transfer::transfer(game.mint_trophy(end, them, ctx), me); + transfer::transfer(game.mint_trophy(end, me, ctx), them); + } else if (end != TROPHY_NONE) { + abort EInvalidEndState + } + } + + + // === Private Helpers === + + /// Address of the player the move is expected from, the address of the + /// other player, and the mark to use for the upcoming move. + fun next_player(game: &Game): (address, address, u8) { + if (game.turn % 2 == 0) { + (game.x, game.o, MARK_X) + } else { + (game.o, game.x, MARK_O) + } + } + + /// Test whether the values at the triple of positions all match each other + /// (and are not all EMPTY). + fun test_triple(game: &Game, x: u8, y: u8, z: u8): bool { + let x = game.board[x as u64]; + let y = game.board[y as u64]; + let z = game.board[z as u64]; + + MARK__ != x && x == y && y == z + } + + /// Create a trophy from the current state of the `game`, that indicates + /// that a player won or drew against `other` player. + fun mint_trophy( + game: &Game, + status: u8, + other: address, + ctx: &mut TxContext, + ): Trophy { + Trophy { + id: object::new(ctx), + status, + board: game.board, + turn: game.turn, + other, + } + } + + public fun burn(game: Game) { + assert!(game.ended() != TROPHY_NONE, ENotFinished); + let Game { id, .. } = game; + id.delete(); + } + + /// Test whether the game has reached an end condition or not. + public fun ended(game: &Game): u8 { + if ( + // Test rows + test_triple(game, 0, 1, 2) || + test_triple(game, 3, 4, 5) || + test_triple(game, 6, 7, 8) || + // Test columns + test_triple(game, 0, 3, 6) || + test_triple(game, 1, 4, 7) || + test_triple(game, 2, 5, 8) || + // Test diagonals + test_triple(game, 0, 4, 8) || + test_triple(game, 2, 4, 6) + ) { + TROPHY_WIN + } else if (game.turn == 9) { + TROPHY_DRAW + } else { + TROPHY_NONE + } + } + + #[syntax(index)] + public fun mark(game: &Game, row: u8, col: u8): &u8 { + &game.board[(row * 3 + col) as u64] + } + + #[syntax(index)] + fun mark_mut(game: &mut Game, row: u8, col: u8): &mut u8 { + &mut game.board[(row * 3 + col) as u64] + } + + // === Test Helpers === + #[test_only] public use fun game_board as Game.board; + #[test_only] public use fun trophy_status as Trophy.status; + #[test_only] public use fun trophy_board as Trophy.board; + #[test_only] public use fun trophy_turn as Trophy.turn; + #[test_only] public use fun trophy_other as Trophy.other; + + #[test_only] + public fun game_board(game: &Game): vector { + game.board + } + + #[test_only] + public fun trophy_status(trophy: &Trophy): u8 { + trophy.status + } + + #[test_only] + public fun trophy_board(trophy: &Trophy): vector { + trophy.board + } + + #[test_only] + public fun trophy_turn(trophy: &Trophy): u8 { + trophy.turn + } + + #[test_only] + public fun trophy_other(trophy: &Trophy): address { + trophy.other + } +} diff --git a/examples/tic-tac-toe/move/tests/owned_tests.move b/examples/tic-tac-toe/move/tests/owned_tests.move new file mode 100644 index 0000000000000..76576db201508 --- /dev/null +++ b/examples/tic-tac-toe/move/tests/owned_tests.move @@ -0,0 +1,323 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module tic_tac_toe::owned_tests { + use sui::test_scenario::{Self as ts, Scenario}; + use sui::transfer::Receiving; + use tic_tac_toe::owned as ttt; + + const ADMIN: address = @0xAD; + const ALICE: address = @0xA; + const BOB: address = @0xB; + + // Dummy key -- this field is only relevant off-chain. + const KEY: vector = vector[]; + + const MARK__: u8 = 0; + const MARK_X: u8 = 1; + const MARK_O: u8 = 2; + + const TROPHY_DRAW: u8 = 1; + const TROPHY_WIN: u8 = 2; + + #[test] + fun x_wins() { + let mut ts = ts::begin(ADMIN); + + let game = ttt::new(ALICE, BOB, KEY, ts.ctx()); + transfer::public_transfer(game, ADMIN); + + ts.place_mark(ALICE, 1, 1); + // . . . + // . X . + // . . . + + ts.place_mark(BOB, 0, 0); + // O . . + // . X . + // . . . + + ts.place_mark(ALICE, 0, 2); + // O . X + // . X . + // . . . + + ts.place_mark(BOB, 1, 0); + // O . X + // O X . + // . . . + + ts.place_mark(ALICE, 2, 0); + // O . X + // O X . + // X . . + + ts.next_tx(ALICE); + assert!(!ts::has_most_recent_for_address(BOB)); + assert!(!ts::has_most_recent_for_address(ALICE)); + assert!(!ts::has_most_recent_for_address(BOB)); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == BOB); + assert!(trophy.status() == TROPHY_WIN); + assert!(trophy.turn() == 5); + assert!(trophy.board() == vector[ + MARK_O, MARK__, MARK_X, + MARK_O, MARK_X, MARK__, + MARK_X, MARK__, MARK__, + ]); + + ts.return_to_sender(trophy); + + ts.next_tx(ADMIN); + let game: ttt::Game = ts.take_from_sender(); + game.burn(); + + ts.end(); + } + + #[test] + fun o_wins() { + let mut ts = ts::begin(ADMIN); + + let game = ttt::new(ALICE, BOB, KEY, ts.ctx()); + transfer::public_transfer(game, ADMIN); + + ts.place_mark(ALICE, 1, 1); + // . . . + // . X . + // . . . + + ts.place_mark(BOB, 2, 2); + // . . . + // . X . + // . . O + + ts.place_mark(ALICE, 0, 2); + // . . X + // . X . + // . . O + + ts.place_mark(BOB, 2, 0); + // . . X + // . X . + // O . O + + ts.place_mark(ALICE, 0, 0); + // X . X + // . X . + // O . O + + ts.place_mark(BOB, 2, 1); + // X . X + // . X . + // O O O + + ts.next_tx(BOB); + assert!(!ts::has_most_recent_for_address(ALICE)); + assert!(!ts::has_most_recent_for_address(ALICE)); + assert!(!ts::has_most_recent_for_address(BOB)); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == ALICE); + assert!(trophy.status() == TROPHY_WIN); + assert!(trophy.turn() == 6); + assert!(trophy.board() == vector[ + MARK_X, MARK__, MARK_X, + MARK__, MARK_X, MARK__, + MARK_O, MARK_O, MARK_O, + ]); + + ts.return_to_sender(trophy); + + ts.next_tx(ADMIN); + let game: ttt::Game = ts.take_from_sender(); + game.burn(); + + ts.end(); + } + + #[test] + fun draw() { + let mut ts = ts::begin(ADMIN); + + let game = ttt::new(ALICE, BOB, KEY, ts.ctx()); + transfer::public_transfer(game, ADMIN); + + ts.place_mark(ALICE, 1, 1); + // . . . + // . X . + // . . . + + ts.place_mark(BOB, 0, 0); + // O . . + // . X . + // . . . + + ts.place_mark(ALICE, 0, 2); + // O . X + // . X . + // . . . + + ts.place_mark(BOB, 2, 0); + // O . X + // . X . + // O . . + + ts.place_mark(ALICE, 1, 0); + // O . X + // X X . + // O . . + + ts.place_mark(BOB, 1, 2); + // O . X + // X X O + // O . . + + ts.place_mark(ALICE, 0, 1); + // O X X + // X X O + // O . . + + ts.place_mark(BOB, 2, 1); + // O X X + // X X O + // O O . + + ts.place_mark(ALICE, 2, 2); + // O X X + // X X O + // O O X + + ts.next_tx(ALICE); + assert!(!ts::has_most_recent_for_address(ALICE)); + assert!(!ts::has_most_recent_for_address(BOB)); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == BOB); + assert!(trophy.status() == TROPHY_DRAW); + assert!(trophy.turn() == 9); + assert!(trophy.board() == vector[ + MARK_O, MARK_X, MARK_X, + MARK_X, MARK_X, MARK_O, + MARK_O, MARK_O, MARK_X, + ]); + + ts.return_to_sender(trophy); + ts.next_tx(BOB); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == ALICE); + assert!(trophy.status() == TROPHY_DRAW); + assert!(trophy.turn() == 9); + assert!(trophy.board() == vector[ + MARK_O, MARK_X, MARK_X, + MARK_X, MARK_X, MARK_O, + MARK_O, MARK_O, MARK_X, + ]); + + ts.return_to_sender(trophy); + + ts.next_tx(ADMIN); + let game: ttt::Game = ts.take_from_sender(); + game.burn(); + + ts.end(); + } + + #[test] + /// Only one player has the TurnCap at any one time. + fun turn_cap_conservation() { + let mut ts = ts::begin(ADMIN); + + let game = ttt::new(ALICE, BOB, KEY, ts.ctx()); + transfer::public_transfer(game, ADMIN); + + ts.next_tx(ADMIN); + assert!(ts::has_most_recent_for_address(ALICE)); + assert!(!ts::has_most_recent_for_address(BOB)); + + ts.place_mark(ALICE, 1, 1); + ts.next_tx(ADMIN); + assert!(!ts::has_most_recent_for_address(ALICE)); + assert!(ts::has_most_recent_for_address(BOB)); + + ts.end(); + } + + #[test] + #[expected_failure(abort_code = ttt::EInvalidLocation)] + fun location_out_of_bounds() { + let mut ts = ts::begin(ADMIN); + + let game = ttt::new(ALICE, BOB, KEY, ts.ctx()); + transfer::public_transfer(game, ADMIN); + + ts.place_mark(ALICE, 3, 3); + abort 0 + } + + #[test] + /// When a position is already marked, the turn cap is returned to + /// the player who made the "false" move, rather than the next + /// player. + fun already_marked() { + let mut ts = ts::begin(ADMIN); + + let game = ttt::new(ALICE, BOB, KEY, ts.ctx()); + transfer::public_transfer(game, ADMIN); + + ts.place_mark(ALICE, 1, 1); + ts.place_mark(BOB, 1, 1); + + ts.next_tx(ADMIN); + assert!(ts::has_most_recent_for_address(BOB)); + assert!(!ts::has_most_recent_for_address(ALICE)); + + let game: ttt::Game = ts.take_from_sender(); + assert!(game.board() == vector[ + MARK__, MARK__, MARK__, + MARK__, MARK_X, MARK__, + MARK__, MARK__, MARK__, + ]); + + ts.return_to_sender(game); + ts.end(); + } + + #[test] + #[expected_failure(abort_code = ttt::ENotFinished)] + fun burn_unfinished_game() { + let mut ts = ts::begin(ADMIN); + + let game = ttt::new(ALICE, BOB, KEY, ts.ctx()); + transfer::public_transfer(game, ADMIN); + + ts.place_mark(ALICE, 1, 1); + + ts.next_tx(ADMIN); + let game: ttt::Game = ts.take_from_sender(); + + game.burn(); + abort 0 + } + + // === Test Helpers === + use fun place_mark as Scenario.place_mark; + + // The current player places a mark at the given location. + fun place_mark(ts: &mut Scenario, player: address, row: u8, col: u8) { + ts.next_tx(player); + + let cap: ttt::TurnCap = ts.take_from_sender(); + cap.send_mark(row, col, ts.ctx()); + + ts.next_tx(ADMIN); + let mut game: ttt::Game = ts.take_from_sender(); + let rcv: Receiving = + ts::most_recent_receiving_ticket(&object::id(&game)); + + game.place_mark(rcv, ts.ctx()); + ts.return_to_sender(game); + } +} diff --git a/examples/tic-tac-toe/move/tests/shared_tests.move b/examples/tic-tac-toe/move/tests/shared_tests.move new file mode 100644 index 0000000000000..a9f53b345b03b --- /dev/null +++ b/examples/tic-tac-toe/move/tests/shared_tests.move @@ -0,0 +1,321 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module tic_tac_toe::shared_tests { + use sui::test_scenario::{Self as ts, Scenario}; + use tic_tac_toe::shared as ttt; + + const ALICE: address = @0xA; + const BOB: address = @0xB; + + const MARK__: u8 = 0; + const MARK_X: u8 = 1; + const MARK_O: u8 = 2; + + const TROPHY_DRAW: u8 = 1; + const TROPHY_WIN: u8 = 2; + + #[test] + fun x_wins() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + + ts.place_mark(ALICE, 1, 1); + // . . . + // . X . + // . . . + + ts.place_mark(BOB, 0, 0); + // O . . + // . X . + // . . . + + ts.place_mark(ALICE, 0, 2); + // O . X + // . X . + // . . . + + ts.place_mark(BOB, 1, 0); + // O . X + // O X . + // . . . + + ts.place_mark(ALICE, 2, 0); + // O . X + // O X . + // X . . + + ts.next_tx(ALICE); + assert!(!ts::has_most_recent_for_address(BOB)); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == BOB); + assert!(trophy.status() == TROPHY_WIN); + assert!(trophy.turn() == 5); + assert!(trophy.board() == vector[ + MARK_O, MARK__, MARK_X, + MARK_O, MARK_X, MARK__, + MARK_X, MARK__, MARK__, + ]); + + ts.return_to_sender(trophy); + + let game: ttt::Game = ts.take_shared(); + game.burn(); + ts.end(); + } + + #[test] + fun o_wins() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + + ts.place_mark(ALICE, 1, 1); + // . . . + // . X . + // . . . + + ts.place_mark(BOB, 2, 2); + // . . . + // . X . + // . . O + + ts.place_mark(ALICE, 0, 2); + // . . X + // . X . + // . . O + + ts.place_mark(BOB, 2, 0); + // . . X + // . X . + // O . O + + ts.place_mark(ALICE, 0, 0); + // X . X + // . X . + // O . O + + ts.place_mark(BOB, 2, 1); + // X . X + // . X . + // O O O + + ts.next_tx(BOB); + assert!(!ts::has_most_recent_for_address(ALICE)); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == ALICE); + assert!(trophy.status() == TROPHY_WIN); + assert!(trophy.turn() == 6); + assert!(trophy.board() == vector[ + MARK_X, MARK__, MARK_X, + MARK__, MARK_X, MARK__, + MARK_O, MARK_O, MARK_O, + ]); + + ts.return_to_sender(trophy); + + let game: ttt::Game = ts.take_shared(); + game.burn(); + ts.end(); + } + + #[test] + fun draw() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + + ts.place_mark(ALICE, 1, 1); + // . . . + // . X . + // . . . + + ts.place_mark(BOB, 0, 0); + // O . . + // . X . + // . . . + + ts.place_mark(ALICE, 0, 2); + // O . X + // . X . + // . . . + + ts.place_mark(BOB, 2, 0); + // O . X + // . X . + // O . . + + ts.place_mark(ALICE, 1, 0); + // O . X + // X X . + // O . . + + ts.place_mark(BOB, 1, 2); + // O . X + // X X O + // O . . + + ts.place_mark(ALICE, 0, 1); + // O X X + // X X O + // O . . + + ts.place_mark(BOB, 2, 1); + // O X X + // X X O + // O O . + + ts.place_mark(ALICE, 2, 2); + // O X X + // X X O + // O O X + + ts.next_tx(ALICE); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == BOB); + assert!(trophy.status() == TROPHY_DRAW); + assert!(trophy.turn() == 9); + assert!(trophy.board() == vector[ + MARK_O, MARK_X, MARK_X, + MARK_X, MARK_X, MARK_O, + MARK_O, MARK_O, MARK_X, + ]); + + ts.return_to_sender(trophy); + ts.next_tx(BOB); + + let trophy: ttt::Trophy = ts.take_from_sender(); + assert!(trophy.other() == ALICE); + assert!(trophy.status() == TROPHY_DRAW); + assert!(trophy.turn() == 9); + assert!(trophy.board() == vector[ + MARK_O, MARK_X, MARK_X, + MARK_X, MARK_X, MARK_O, + MARK_O, MARK_O, MARK_X, + ]); + + ts.return_to_sender(trophy); + + let game: ttt::Game = ts.take_shared(); + game.burn(); + ts.end(); + } + + #[test] + #[expected_failure(abort_code = ttt::EWrongPlayer)] + /// Moves from the wrong player are rejected + fun wrong_player() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + ts.place_mark(BOB, 0, 0); + abort 0 + } + + #[test] + #[expected_failure(abort_code = ttt::EWrongPlayer)] + /// Moves from a player not in the game are rejected + fun random_player() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + ts.place_mark(@0xC, 0, 0); + abort 0 + } + + #[test] + #[expected_failure(abort_code = ttt::EInvalidLocation)] + fun location_out_of_bounds() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + ts.place_mark(ALICE, 3, 3); + abort 0 + } + + #[test] + #[expected_failure(abort_code = ttt::EAlreadyFilled)] + /// When a position is already marked, the turn cap is returned to + /// the player who made the "false" move, rather than the next + /// player. + fun already_marked() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + + ts.place_mark(ALICE, 1, 1); + ts.place_mark(BOB, 1, 1); + abort 0 + } + + #[test] + #[expected_failure(abort_code = ttt::EAlreadyFinished)] + fun already_finished() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + + ts.place_mark(ALICE, 1, 1); + // . . . + // . X . + // . . . + + ts.place_mark(BOB, 0, 0); + // O . . + // . X . + // . . . + + ts.place_mark(ALICE, 0, 2); + // O . X + // . X . + // . . . + + ts.place_mark(BOB, 1, 0); + // O . X + // O X . + // . . . + + ts.place_mark(ALICE, 2, 0); + // O . X + // O X . + // X . . + + // Shouldn't work because the game has already finished. + ts.place_mark(BOB, 2, 0); + // O . X + // O X . + // X . O + + abort 0 + } + + #[test] + #[expected_failure(abort_code = ttt::ENotFinished)] + fun burn_unfinished_game() { + let mut ts = ts::begin(ALICE); + + ttt::new(ALICE, BOB, ts.ctx()); + ts.place_mark(ALICE, 1, 1); + + ts.next_tx(BOB); + let game: ttt::Game = ts.take_shared(); + game.burn(); + abort 0 + } + + // === Test Helpers === + use fun place_mark as Scenario.place_mark; + + // The current player places a mark at the given location. + fun place_mark(ts: &mut Scenario, player: address, row: u8, col: u8) { + ts.next_tx(player); + + let mut game: ttt::Game = ts.take_shared(); + game.place_mark(row, col, ts.ctx()); + ts::return_shared(game); + } +}