close(); } die($msg); } /** * The database wrapper class. * * Database is MySQL, created as: * * CREATE TABLE ``.`moves` ( * `channel` TINYTEXT NOT NULL, * `step` TINYINT NOT NULL, * `board` CHAR(9) NOT NULL DEFAULT '.........', * PRIMARY KEY (`channel`(255), `step`), * KEY `channel` (`channel`(255)) * ) ENGINE = InnoDB; * * CREATE TABLE ``.`status` ( * `channel` TINYTEXT NOT NULL, * `userO` TINYTEXT NOT NULL, * `userX` TINYTEXT NOT NULL, * `ongoing` BOOLEAN NOT NULL DEFAULT TRUE, * `nextstep` TINYINT NOT NULL DEFAULT 0, * PRIMARY KEY (`channel`(255)) * ) ENGINE = InnoDB; */ class Db { var $db_link; function Db() { $this->db_link = mysqli_connect(MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD) or error('Could not connect: ' . mysqli_connect_error()); $this->execute("SET NAMES 'utf8'"); mysqli_select_db($this->db_link, MYSQL_DATABASE); } function query($sql) { $result = mysqli_query($this->db_link, $sql); if (!$result) { return false; } $array = array(); while ($row = mysqli_fetch_array($result)) { $array[] = $row; } return $array; } function execute($sql) { mysqli_query($this->db_link, $sql); } function close() { mysqli_close($this->db_link); } } /** * Format a string or an array of strings by adding \n after each string. */ function format_array($string_or_array) { if (is_array($string_or_array)) { $result = ''; foreach($string_or_array as $line) { $result .= "$line\n"; } return $result; } return "$string_or_array\n"; } /** * Return the message to slack and die. * * $formatted, $prefix and $suffix can be either array of strings or a string. * * @param $formatted array of strings to be wrapped with ```, one each line. * @param $db database to close before dying. * @param $prefix array of strings before $formatted. * @param $suffix array of strings after $formatted. */ function return_msg($formatted, $db = null, $prefix = null, $suffix = null) { $json['response_type'] = 'in_channel'; $json['text'] = ''; if ($prefix) { $json['text'] .= format_array($prefix); } if ($formatted) { $json['text'] .= "```\n"; $json['text'] .= format_array($formatted); $json['text'] .= "```\n"; } if ($suffix) { $json['text'] .= format_array($suffix); } header('Content-Type: application/json'); echo(json_encode($json)); if ($db != null) { $db->close(); } die(); } /** * Show command usage and die. */ function show_help() { $arg0 = $_POST['command']; return_msg(array( 'Slack Tic-Tac-Toe game.', '', "Usage: $arg0 help|show|stop|@username|move x y", '', 'Subcommands:', " help: This help message.", " show: Show the current game on this channel.", " stop: Stop the current game on this channel.", " replay: Replay the current or last game on this channel.", " @username: Start a new game on this channel between yourself and @username.", " @username moves first.", " move x y: Make your next move on x,y (0 <= x,y <= 2)", )); } /** * Get the status table result for $channel. */ function get_status($db, $channel) { $table = TABLE_STATUS; $result = $db->query( 'SELECT `userO`, `userX`, `ongoing`, `nextstep` FROM ' . "`$table` WHERE `channel` = '$channel' LIMIT 1"); if ($result) { $result = $result[0]; } return $result; } /** * Check whether there's an ongoing game from a channel's status. */ function is_ongoing($status) { if ($status) { return $status['ongoing']; } return false; } /** * Render the header of the board. */ function draw_board_header($userO, $userX) { return "O: @$userO, X: @$userX"; } /** * Render the board of game. * * @param $board the board, a string with 9 characters. * @param $userO * @param $userX if both $userO and $userX are set, draw the header as well. */ function draw_board($board, $userO = null, $userX = null) { $result = ''; if ($userO && $userX) { $result = draw_board_header($userO, $userX) . "\n"; } $board = str_replace('.', ' ', $board); $newline = '|---+---+---|'; for ($i = 0; $i < 3; $i++) { $line = '|'; for ($j = 0; $j < 3; $j++) { $index = $i * 3 + $j; $char = substr($board, $index, 1); $line .= " $char |"; } $result .= "$newline\n"; $result .= "$line\n"; } $result .= $newline; return $result; } function starts_with($string, $prefix) { return strpos($string, $prefix) === 0; } /** * Make a SQL query to get the board. */ function get_board($db, $channel, $step) { $table = TABLE_MOVES; $result = $db->query( "SELECT `board` from `$table` WHERE " . "`channel` = '$channel' AND `step` = '$step' LIMIT 1"); if ($result) { return $result[0]['board']; } return false; } /** * Get the token at ($x, $y) from $board. */ function get_token($board, $x, $y) { return substr($board, $x * 3 + $y, 1); } /** * Set the token at ($x, $y) to $token from $board. */ function set_token($board, $x, $y, $token) { $board[$x * 3 + $y] = $token; return $board; } /** * Check if we have a winner at $board on $line coordinates. * * @param $board the board, must be a string of 9 characters * @param $line the coordinates, must be an array of 3 integers * @param $userO * @param $userX * * @return winner username, or false if no winner yet. */ function check_winner_line($board, $line, $userO, $userX) { $token = $board[$line[0]]; if ($token == '.') { return false; } if ($token != $board[$line[1]] || $token != $board[$line[2]]) { return false; } switch($token) { case 'O': return $userO; case 'X': return $userX; } } /** * Check if we have a winner at $board. * * @return true if it's a draw, false if no winner yet, or winner username. */ function check_winner($board, $userO, $userX, $index = null) { if ($index === null) { foreach(WINNER_LINES as $line) { $winner = check_winner_line($board, $line, $userO, $userX); if ($winner) { return $winner; } } } else { foreach(LINES_TO_CHECK[$index] as $i) { $line = WINNER_LINES[$i]; $winner = check_winner_line($board, $line, $userO, $userX); if ($winner) { return $winner; } } } return strpos($board, '.') === false; } /** * Get the latest board on $channel */ function get_latest_board($db, $status, $channel) { $board = EMPTY_BOARD; $nextstep = $status['nextstep']; if ($nextstep > 0) { $board = get_board($db, $channel, $nextstep - 1); } return $board; } // Subcommand handling functions function do_stop($db, $status, $channel) { if (is_ongoing($status)) { $table = TABLE_STATUS; $db->execute( "UPDATE `$table` SET `ongoing` = FALSE WHERE `channel` = '$channel'"); $user1 = $status['userO']; $user2 = $status['userX']; return_msg(null, $db, array( "Stopped the game between @$user1 and @$user2.", 'Use replay subcommand to replay the game.', )); } else { return_msg(null, $db, 'Error: No game ongoing on this channel.'); } } function do_show($db, $status, $channel) { if (is_ongoing($status)) { $table = TABLE_MOVES; $board = get_latest_board($db, $status, $channel); $userO = $status['userO']; $userX = $status['userX']; if ($status['nextstep'] % 2 == 0) { $nextuser = $status['userO']; } else { $nextuser = $status['userX']; } return_msg( draw_board($board, $userO, $userX), $db, "@$userX is challenging @$userO:", "It's @$nextuser's turn."); } else { return_msg(null, $db, 'Error: No game ongoing on this channel.'); } } function do_replay($db, $status, $channel) { if (!$status) { return_msg(null, $db, 'No game on this board to replay.'); } $userO = $status['userO']; $userX = $status['userX']; $formatted = array(); $formatted[] = draw_board_header($userO, $userX); $lastboard = EMPTY_BOARD; $table = TABLE_MOVES; $result = $db->query( "SELECT `step`, `board` FROM `$table` WHERE `channel` = '$channel' " . "ORDER BY `step` ASC"); foreach($result as $row) { $step = $row['step']; $board = $row['board']; $lastboard = $board; $formatted[] = "Step $step:"; $formatted[] = draw_board($board); } if ($lastboard == EMPTY_BOARD) { $formatted[] = draw_board($lastboard); $formatted[] = '(No moves.)'; } $suffix = null; $winner = check_winner($lastboard, $userO, $userX); if ($winner) { if ($winner === true) { $suffix = 'It\'s a draw.'; } else { $suffix = "@$winner won."; } } return_msg($formatted, $db, "@$userX challenged @$userO.", $suffix); } function do_at($db, $status, $channel, $command) { if (strpos($command, ' ')) { return_msg(null, $db, array( "Error: Invalid command \"$command\".", 'You cannot add anything after @username.', )); } if (is_ongoing($status)) { return_msg(null, $db, array( 'Error: There is an ongoing game on this channel.', 'Please either finish it or use stop subcommand to stop it first.', )); } $table = TABLE_MOVES; $db->execute("DELETE FROM `$table` WHERE `channel` = '$channel'"); $table = TABLE_STATUS; if ($status) { $db->execute("DELETE FROM `$table` WHERE `channel` = '$channel'"); } $userO = substr($command, 1); if (preg_match(USERNAME_PATTERN, $userO) != 1) { return_msg(null, $db, "Error: Invalid username \"@$userO\""); } $userX = $_POST['user_name']; $db->execute( "INSERT INTO `$table` SET " . "`channel` = '$channel', `userO` = '$userO', `userX` = '$userX'"); return_msg( draw_board(EMPTY_BOARD, $userO, $userX), $db, array( "@$userX challenges @$userO for a Tic-Tac-Toe game!", 'There can be only one!', '(Or zero. The only winning move is not to play.)', ), "Now it's @$userO's move."); } function do_move($db, $status, $channel, $command) { if (!is_ongoing($status)) { return_msg(null, $db, 'Error: No game ongoing on this channel.'); } if ($status['nextstep'] % 2 == 0) { $nextuser = $status['userO']; $nexttoken = 'O'; $otheruser = $status['userX']; } else { $nextuser = $status['userX']; $nexttoken = 'X'; $otheruser = $status['userO']; } if ($nextuser != $_POST['user_name']) { return_msg(null, $db, array( 'Error: It\'s not your turn.', "(It's @$nextuser's turn.)", )); } $moves = explode(' ', $command); if (sizeof($moves) != 3) { return_msg(null, $db, array( 'Error: Invalid move subcommand.', '(Please use "move x y" whith 0 <= x,y <= 2)', )); } $x = $moves[1]; $y = $moves[2]; $xx = (string) ((int) $x); $yy = (string) ((int) $y); if ($x != $xx || $y != $yy) { return_msg(null, $db, array( 'Error: Invalid move subcommand.', '(Please use "move x y" whith 0 <= x,y <= 2)', )); } if ($x < 0 || $x > 2 || $y < 0 || $y > 2) { return_msg(null, $db, "Error: Move subcommand \"$command\" out of range."); } $board = get_latest_board($db, $status, $channel); $nextstep = $status['nextstep']; $userO = $status['userO']; $userX = $status['userX']; if (get_token($board, $x, $y) != '.') { return_msg( draw_board($board, $userO, $userX), $db, array( 'Error: Invalid move.', "($x, $y) is already occupied:", )); } $board = set_token($board, $x, $y, $nexttoken); $db->execute('START TRANSACTION'); $db->execute( 'INSERT INTO `' . TABLE_MOVES . "` VALUES('$channel', $nextstep, '$board')"); $winner = check_winner($board, $userO, $userX, $x * 3 + $y); if ($winner) { $db->execute( 'UPDATE `' . TABLE_STATUS . '` SET `nextstep` = ' . ($nextstep + 1) . ", `ongoing` = FALSE WHERE `channel` = '$channel'"); $db->execute('COMMIT'); if ($winner === true) { $msg = array( 'It\'s a draw.', 'The only winning move is not to play.', ); } else { $msg = array( "Congratulations! @$winner won the game!", 'There can be only one!', ); } return_msg(draw_board($board, $userO, $userX), $db, $msg); } else { $db->execute( 'UPDATE `' . TABLE_STATUS . '` SET `nextstep` = ' . ($nextstep + 1) . " WHERE `channel` = '$channel'"); $db->execute('COMMIT'); return_msg( draw_board($board, $userO, $userX), $db, "@$nextuser just made a move on ($x, $y):", "Now it's @$otheruser's turn."); } } // Main code starts here. if ($_POST['token'] != TOKEN) { error('Invalid token.'); } $channel = $_POST['channel_id']; if (!$channel) { error('Missing channel_id.'); } if (preg_match(CHANNEL_PATTERN, $channel) != 1) { error('Invalid channel_id.'); } if (preg_match(USERNAME_PATTERN, $_POST['user_name']) != 1) { error('Invalid user_name.'); } $command = $_POST['text']; if (!$command) { // Default command is to show the current game $command = 'show'; } $command = trim($command); if ($command == 'help') { show_help(); } $db = new Db(); $status = get_status($db, $channel); if ($command == 'stop') { do_stop($db, $status, $channel); } if ($command == 'show') { do_show($db, $status, $channel); } if ($command == 'replay') { do_replay($db, $status, $channel); } if (starts_with($command, '@')) { do_at($db, $status, $channel, $command); } if (starts_with($command, 'move ')) { do_move($db, $status, $channel, $command); } return_msg(null, $db, array( "Error: Unrecognized subcommand \"$command\".", "You can use help subcommand to get help.", )); // Won't reach here, but just in case. $db->close(); ?>