May 25, 2022

ПИШЕМ БЛОКЧЕЙН ИГРУ НА SOLIDITY

Напишем смарт-контракт всем известной игры «Камень, ножницы, бумага». Контракт будет иметь свой нативный токен, которым будут вестись все расчеты между участниками, игра будет на внутренние токены, смарт-контракт напишем для сети Polygon исключительно из соображений экономии комиссии за деплой контракта, это обойдется нам меньше 1$, в то время как в Ethereum комса встать может нам в 200$.

Тут мы напишем сам смарт-контракт без его последующей интеграции в какой-то UI, то-есть взаимодействовать с ним можно будет только по средствам вызова нужных функций. Часть где мы будем использовать данный смарт контракт вместе с например телеграм ботом или какой-то вебкой я опишу отдельно.

Вкратце о контракте

Будем наследоваться от смарт-конракта токена ERC20 предоставленным командой OpenZeppelin. Будет функции установки стоимости игры, просмотр истории игр, с каждого купленного взноса будет удерживаться 10% на джекпот или комсу) по итоку победитель возвращает ticket price * 2 player * 0.8.

Сам контракт можно писать в разных IDE, например в VSCode, но лично я предпочитаю remix, это online IDE в которой можно сразу деплоить наш контракт как в тестнет так и мэиннет, дебажить и использовать разные версии компилятора.

Создадим новый файл RSP.sol, и пропишем версию solidity, импортируем контракт ERC20 для нашего нативного токена и создадим контракт унаследовав ERC20

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.11;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol";

contract RSP is ERC20 {
}

Объявим наши переменные для дальнейшей работы

// Список из сыгранных игр
mapping(uint256 => _game[]) private _gameHistory;

// Кол-во выпущенных токенов
uint256 private _totalSupply;
// Идентификатор актуальной игры
uint256 private _gameId;
// Кубышка
uint256 private _jackPot;
// Стоимость одной игры
uint256 private _gameCost;
// 10**18 для упрощения
uint256 private _x;

// Действие игрока
int8[] private _playerAction;

// Список адресов игроков
address payable[] _players;
// Владелец контракта
address private _contractOwner;

// Название токена
string private _name;
// Короткое символичное название токена
string private _symbol;

// Событие, которое будет вызываться при входе участника
event NumberPlay(uint256 number);

// Структура в срезе 1 игры, для истории
struct _game {
  address player_1;
  address player_2;
  int8 player_1_action;
  int8 player_2_action;
  int8 player_win;
}

Создадим конструктор, по сути это функция которая выполняется один раз при деплое смарт-контракта и полезна нам тем, что в ней мы задами стартовые дефолтные настройки для игры, так же там укажем нашего владельца контракта, который в дальнейшем сможет, например изменить стоимость входа в игру.
И заминтим от общего кол-ва токенов, а это у нас 1000 RSP токенов, на два адреса. Первый адрес контракта на него мы выпустим 20% от общего кол-ва, а второй адрес, это адрес владельца контракта, на него мы отправим 80% всех токенов RSP.

constructor() ERC20 ("Rock Scissors Paper", "RSP"){
        _x = 10 ** 18;
        _totalSupply = 1000 * _x;
        _jackPot = 0;
        _gameCost = 3 * _x;
        _gameId = 1;
        _contractOwner = msg.sender;

        _mint(address(this), _totalSupply/100*20);
        _mint(_contractOwner, _totalSupply/100*80);
 
    }

Данный контракт можно уже задеплоить в сеть, и обнаружить что на наш адрес кошелька уже упали наши токены, но наша ведь задача не в этом, поэтому продолжаем накидывать г*вно на вентилятор и расширять наш функционал.

Создадим простые геттеры, чтобы можно было получить из нашего будущего приложения какие-то данные, например стоимость игры или историю игр, да на крайний случай проверить себя в списке игроков в случае участия по идентификатору игры. Все функции имеют public модификатор, что дает возможность делать запросы из вне.

/**
* @dev Returns the game identificator 
*/
function gameId() public view returns (uint256){
    return _gameId;
}

/**
* @dev Returns the total jackpot pot 
*/
function jackPot() public view returns (uint256){
    return _jackPot;
}

/**
* @dev Returns the game cost 
*/
function gameCost() public view returns (uint256){
    return _gameCost;
}    
/**
* @dev Returns current players 
*/
function players() public view returns(address payable[] memory) {
    return _players;
}

/**
* @dev Returns the game history by id
*/
function gameHistory(uint256 id) public view returns (_game[] memory) {
    return _gameHistory[id];
}

Возможно нам потребуется изменить стоимость игры, не создавать же нам новый смарт-контракт для этого. Напишем сеттер для стоимости входного билета

/**
* @dev Set the game cost 
* Integer must beetwen 1 and 999
*/
function setGameCost(uint256 cost) public onlyContractOwner returns (bool){
    require(cost >= 1 && cost < 1000, "Invalid cost, 1-999");
    _gameCost = cost * _x;
    return true;
}

Теперь напишем функцию приема платежа за игру и передачи в нее, собственно, действия, а действий у нас 3: это камень, ножницы или бумага.
В данную функцию будут передаваться 2 параметра, это сумма и действие. Из проверок после вызова это то, что передаваемая сумма соответствует стоимости билета, нам лишнего не надо, но и если денег нет — простите). Так же проверка на передаваемое действие, ограничимся условными 1, 2 или 3. После на адрес контракта от игрока мы переводим стоимость игры заданную ранее, и проверяем на количество игроков на данный момент в принимающих участие. Наша игра подразумевает 2 игрока, поэтому когда игрок только 1, то необходимо просто выйти из функции зарегистрировав игрока. В случае, если вы уже второй игрок, то логично было бы сразу инициализировать процесс выбора победителя и расчета ревардов.

/**
    * @dev See {IERC20-transfer}.
    *
    * Requirements:
    *
    * - the caller must have a balance of at least `amount`.
    */
function transferPerGame(uint256 amount, int8 action) external returns (uint256) {
    require(amount == gameCost(), "Sent token are not equal to the cost of the game");
    require(0 < action && action < 4, "Invalid value for action (1-3)");

    address to = address(this);
    address owner = _msgSender();
    uint256 curGameId = gameId();
    _transfer(owner, to, amount);
    if (players().length < 2) {  
        _players.push(payable(owner));
        _playerAction.push(action);
        if (players().length == 2) {
            int8 indexWinner = calculateWinner(_playerAction[0], _playerAction[1]);
            _gameHistory[curGameId].push(_game(_players[0],_players[1],_playerAction[0],_playerAction[1],indexWinner));
            setWinner(indexWinner);
        }
    }
    emit NumberPlay(curGameId);
    return curGameId;
}

Реализуем функцию calculateWinner(), которая будет рассчитывать кто же все таки победил в этом раунде. Передаем в нее действие первого игрока и действие второго игрока, а дальше все просто:
— камень бьет ножницы— ножницы бьют бумагу— бумага бьет камень

и возвращаем число 0 в случае победы игрока под номером 1, число 1 в случае победы игрока под номером 2, ну а когда будет ничья мы будем возвращать -1

/* @dev calculate the winner
* Requirements:
*   - Action of the first player 
*   - Action of the second player
*   1 - Stone | 2 - Scissor | 3 - Paper
*   1 => 2 => 3 => 1 => 2 => 3
*/
function calculateWinner(int8 p1, int8 p2) private pure returns(int8) {
    // 1 - k; 2 - n; 3 - b
    if (p1 > p2){
        //return (p1 - p2) > 1 ? 0 : 1;
        if ((p1 - p2) > 1){
            return 0;
        }
        else {
            return 1;
        }
    }
    else if (p1 < p2){
        //return (p2 - p1) > 1 ? 1 : 0;
        if ((p2 - p1) > 1) {
            return 1;
        }
        else {
            return 0;
        }
    }
    return -1;
}

После того как определили победителя у нас идет вызов функции распределения ревардов setWinner(). Если в данной игре есть победитель, то на его счет упадут токены рассчитанные по формуле (gameCost() * 2) / 100 * 90, это 90% от стоимости двух входных билетов, оставшиеся 10% летят в наш банк, можно реализовать супер лотерею на которой будет один или несколько счастливчиков, либо оставить в виде комиссии. Реализации в данном контексте нет, остаток просто оседает на адресе контракта. В случае когда победителей нет, мы честно возвращаем стоимости билетов без издержек.

/* @dev calculate the prize and transfer to the participants
*
*/
function setWinner(int indexWinner) private {

    if (indexWinner >= 0) {
        _jackPot = jackPot() + ((gameCost() * 2) / 100 * 10 );
        uint256 reward = (gameCost() * 2) / 100 * 90;
        if (indexWinner == 0) {
            _transfer(address(this),_players[0], reward);
        }
        else if (indexWinner == 1) {
            _transfer(address(this),_players[1], reward);
        }
    }
    else if (indexWinner < 0) {
        _transfer(address(this),_players[0], gameCost());
        _transfer(address(this),_players[1], gameCost());
    }

    _gameId++;
    _players = new address payable[](0);
    _playerAction = new int8[](0);
}

Напоследок создадим модификатор. Модификатор хорош тем что мы в нем можем прописать кучу различных условий и применить к функции не засоряя код и делая его более френдли. Данный модификатор как пример мы используем на функции установки стоимости за участие в игре, к функции после модификатора public мы добавим название своего модификатора onlyContractOwner. По сути при вызове функции установки стоимости будет проверяться является ли адрес владельцем контракта, но вы можете прописать любые правила.

modifier onlyContractOwner() {
    require(_contractOwner == _msgSender());
    _;
}

Наш смарт-контракт готов, осталось сделать деплой и проверить все.

Код на github