Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Examples/Move] Tic-tac-toe #18525

Merged
merged 5 commits into from
Jul 11, 2024
Merged
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
9 changes: 9 additions & 0 deletions examples/tic-tac-toe/move/Move.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "tic_tac_toe"
edition = "2024.beta"

[dependencies]
Sui = { local = "../../../crates/sui-framework/packages/sui-framework" }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we depend on the local framework here? I remember on the trading demo, we did depend on a live sui branch (testnet iirc) to make these easy to copy + play with outside of the monorepo. I think whichever route we choose, we should do it consistently!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should depend on the local one everywhere for now, so that CI doesn't have to refetch the sui repo. This problem should go away once we eliminate the sui framework as an explicit dependency.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can make the change to the trading contracts!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


[addresses]
tic_tac_toe = "0x0"
321 changes: 321 additions & 0 deletions examples/tic-tac-toe/move/sources/owned.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
// 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<u8>,
/// 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<u8>,
}

/// 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<u8>,
/// 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;
Comment on lines +84 to +91
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we wait a few weeks and just use enums?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should wait a few weeks to land the docs change, but we should come back and update it once enums is generally available, yes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging for @tzakian


// === Errors ===

#[error]
const EInvalidLocation: vector<u8> =
b"Move was for a position that doesn't exist on the board";

#[error]
const EWrongPlayer: vector<u8> =
b"Game expected a move from another player";

#[error]
const ENotFinished: vector<u8> =
b"Game has not reached an end condition";

#[error]
const EAlreadyFinished: vector<u8> =
b"Can't place a mark on a finished game";

#[error]
const EInvalidEndState: vector<u8> =
b"Game reached an end state that wasn't expected";

// === 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<u8>, 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<Mark>,
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)
}
Comment on lines +256 to +260
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (game.turn % 2 == 0) {
(game.x, game.o, MARK_X)
} else {
(game.o, game.x, MARK_O)
}
if (game.turn % 2 == 0) (game.x, game.o, MARK_X)
else (game.o, game.x, MARK_O)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not a huge fan of this change, because not having the two cases aligned makes it difficult to note the differences between them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (game.turn % 2 == 0) {
(game.x, game.o, MARK_X)
} else {
(game.o, game.x, MARK_O)
}
if (game.turn % 2 == 0) (game.x, game.o, MARK_X)
else (game.o, game.x, MARK_O)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kidding of course :p
And fair enough. I would always say to do what you think is more readable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't tempt me, in a past life, I've been known to do worse.

}

/// 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<u8> {
game.board
}

#[test_only]
public fun trophy_status(trophy: &Trophy): u8 {
trophy.status
}

#[test_only]
public fun trophy_board(trophy: &Trophy): vector<u8> {
trophy.board
}

#[test_only]
public fun trophy_turn(trophy: &Trophy): u8 {
trophy.turn
}

#[test_only]
public fun trophy_other(trophy: &Trophy): address {
trophy.other
}
}
Loading
Loading