changeset 632:e5d0f4289e04

Plugin tictactoe: brand new plugin, closes #393 @6h
author David Demelier <markand@malikania.fr>
date Tue, 13 Mar 2018 13:51:17 +0100
parents 1fa9e5222e87
children c07819d1d306
files CHANGES.md doc/html/index.md plugins/CMakeLists.txt plugins/tictactoe/tictactoe.js plugins/tictactoe/tictactoe.md tests/src/plugins/CMakeLists.txt tests/src/plugins/tictactoe/CMakeLists.txt tests/src/plugins/tictactoe/main.cpp
diffstat 8 files changed, 738 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.md	Wed Mar 07 21:00:56 2018 +0100
+++ b/CHANGES.md	Tue Mar 13 13:51:17 2018 +0100
@@ -40,7 +40,8 @@
 
 Plugins:
 
-  - Introduce brand new joke plugin (#609).
+  - Introduce brand new joke plugin (#609),
+  - Introduce brand new tictactoe plugin (#393).
 
 irccd 2.2.0 2017-09-26
 ----------------------
--- a/doc/html/index.md	Wed Mar 07 21:00:56 2018 +0100
+++ b/doc/html/index.md	Tue Mar 13 13:51:17 2018 +0100
@@ -48,6 +48,7 @@
   - [logger](plugin/logger.html)
   - [plugin](plugin/plugin.html)
   - [roulette](plugin/roulette.html)
+  - [tictactoe](plugin/tictactoe.html)
 
 # Development
 
--- a/plugins/CMakeLists.txt	Wed Mar 07 21:00:56 2018 +0100
+++ b/plugins/CMakeLists.txt	Tue Mar 13 13:51:17 2018 +0100
@@ -28,6 +28,7 @@
     logger
     plugin
     roulette
+    tictactoe
     CACHE INTERNAL ""
 )
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/tictactoe/tictactoe.js	Tue Mar 13 13:51:17 2018 +0100
@@ -0,0 +1,352 @@
+/*
+ * tictactoe.js -- tictactoe game for IRC
+ *
+ * Copyright (c) 2013-2018 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+// Plugin information.
+info = {
+    author: "David Demelier <markand@malikania.fr>",
+    license: "ISC",
+    summary: "A tictactoe game for IRC",
+    version: "@IRCCD_VERSION@"
+};
+
+// Modules.
+var Plugin = Irccd.Plugin;
+var Util = Irccd.Util;
+
+// Formats.
+Plugin.format = {
+    "draw":     "nobody won",
+    "invalid":  "#{nickname}, please select a valid opponent",
+    "running":  "#{nickname}, the game is already running",
+    "turn":     "#{nickname}, it's your turn",
+    "used":     "#{nickname}, this square is already used",
+    "win":      "#{nickname}, congratulations, you won!"
+};
+
+/**
+ * Create a game.
+ *
+ * This function creates a game without any checks.
+ *
+ * @param server the server object
+ * @param channel the channel
+ * @param origin the source origin
+ * @param target the target nickname
+ */
+function Game(server, channel, origin, target)
+{
+    this.server = server;
+    this.origin = origin;
+    this.target = target;
+    this.channel = channel;
+    this.players = [ Util.splituser(origin), target ];
+    this.player = Math.floor(Math.random() * 2);
+    this.grid = [
+        [ '.', '.', '.' ],
+        [ '.', '.', '.' ],
+        [ '.', '.', '.' ]
+    ];
+}
+
+// Pending games requests checking for names listing.
+Game.requests = {};
+
+// List of games running.
+Game.map = {};
+
+/**
+ * Create a unique id.
+ *
+ * @param server the server object
+ * @param channel the channel
+ * @return the id
+ */
+Game.id = function (server, channel)
+{
+    return channel + "@" + server.toString();
+}
+
+/**
+ * Get a running game or undefined.
+ *
+ * @param server the server object
+ * @param channel the channel
+ * @return the object or undefined if not running
+ */
+Game.find = function (server, channel)
+{
+    return Game.map[Game.id(server, channel)];
+}
+
+/**
+ * Request a game after the name list gets received.
+ *
+ * @param server the server object
+ * @param channel the channel
+ * @param origin the originator
+ * @return the object or undefined if not running
+ */
+Game.postpone = function (server, channel, origin, target)
+{
+    /*
+     * Get list of users on the channel to avoid playing against a non existing
+     * target.
+     */
+    Game.requests[Game.id(server, channel)] = new Game(server, channel, origin, target);
+    server.names(channel);
+}
+
+/**
+ * Populate a set of keywords.
+ *
+ * @param server the server object
+ * @param origin the originator
+ * @param channel the channel
+ * @return an object of predefined keywords
+ */
+Game.keywords = function (server, channel, origin)
+{
+    var kw = {
+        channel: channel,
+        command: server.info().commandChar + Plugin.info().name,
+        plugin: Plugin.info().name,
+        server: server.info().name
+    };
+
+    if (origin) {
+        kw.origin = origin;
+        kw.nickname = Util.splituser(origin);
+    }
+
+    return kw;
+}
+
+/**
+ * Tells if a game is pending or running.
+ *
+ * @param server the server object
+ * @param channel the channel
+ * @return true if any
+ */
+Game.exists = function (server, channel)
+{
+    var id = Game.id(server, channel);
+
+    return Game.requests[id] || Game.map[id];
+}
+
+/**
+ * Delete a game from the registry.
+ *
+ * @param server the server object
+ * @param channel the channel
+ */
+Game.remove = function (server, channel)
+{
+    delete Game.map[Game.id(server, channel)];
+}
+
+/**
+ * Erase games when some players leave channels.
+ *
+ * @param server the server object
+ * @param origin the originator
+ * @param channel the channel
+ */
+Game.clear = function (server, user, channel)
+{
+    var nickname = Util.splituser(user);
+    var game = Game.find(server, channel);
+
+    if (game && (game.players[0] === nickname || game.players[1] === nickname))
+        Game.remove(server, channel);
+}
+
+/**
+ * Show the game grid and the next player line.
+ */
+Game.prototype.show = function ()
+{
+    var kw = Game.keywords(this.server, this.channel);
+
+    // nickname is the current player.
+    kw.nickname = this.players[this.player];
+
+    this.server.message(this.channel, "  a b c");
+    this.server.message(this.channel, "1 " + this.grid[0].join(" "));
+    this.server.message(this.channel, "2 " + this.grid[1].join(" "));
+    this.server.message(this.channel, "3 " + this.grid[2].join(" "));
+
+    if (this.hasWinner())
+        this.server.message(this.channel, Util.format(Plugin.format.win, kw));
+    else if (this.hasDraw())
+        this.server.message(this.channel, Util.format(Plugin.format.draw, kw));
+    else
+        this.server.message(this.channel, Util.format(Plugin.format.turn, kw));
+}
+
+/**
+ * Tells if it's the nickname's turn.
+ *
+ * @param nickname the nickname to check
+ * @return true if nickname is allowed to play
+ */
+Game.prototype.isTurn = function (nickname)
+{
+    return this.players[this.player] == nickname;
+}
+
+/**
+ * Place the column and row as the current player.
+ *
+ * @param column the column (a, b or c)
+ * @param row the row (1, 2 or 3)
+ */
+Game.prototype.place = function (column, row, origin)
+{
+    var columns = { a: 0, b: 1, c: 2 };
+    var rows = { 1: 0, 2: 1, 3: 2 };
+
+    column = columns[column];
+    row = rows[row];
+
+    var kw = Game.keywords(this.server, this.channel, origin);
+
+    if (this.grid[row][column] !== '.') {
+        this.server.message(this.channel, Util.format(Plugin.format.used, kw));
+        return false;
+    }
+
+    this.grid[row][column] = this.player === 0 ? 'x' : 'o';
+
+    // Do not change if game is finished.
+    if (!this.hasWinner() && !this.hasDraw())
+        this.player = this.player === 0 ? 1 : 0;
+
+    return true;
+}
+
+/**
+ * Check if there is a winner.
+ *
+ * @return true if there is a winner
+ */
+Game.prototype.hasWinner = function ()
+{
+    var lines = [
+        [ [ 0, 0 ], [ 0, 1 ], [ 0, 2 ] ],
+        [ [ 1, 0 ], [ 1, 1 ], [ 1, 2 ] ],
+        [ [ 2, 0 ], [ 2, 1 ], [ 2, 2 ] ],
+        [ [ 0, 0 ], [ 1, 0 ], [ 2, 0 ] ],
+        [ [ 0, 1 ], [ 1, 1 ], [ 2, 1 ] ],
+        [ [ 0, 2 ], [ 1, 2 ], [ 2, 2 ] ],
+        [ [ 0, 0 ], [ 1, 1 ], [ 2, 2 ] ],
+        [ [ 0, 2 ], [ 1, 1 ], [ 2, 0 ] ]
+    ];
+
+    for (var i = 0; i < lines.length; ++i) {
+        var p1 = lines[i][0];
+        var p2 = lines[i][1];
+        var p3 = lines[i][2];
+
+        var result = this.grid[p1[0]][p1[1]] === this.grid[p2[0]][p2[1]] &&
+                     this.grid[p2[0]][p2[1]] === this.grid[p3[0]][p3[1]] &&
+                     this.grid[p3[0]][p3[1]] !== '.';
+
+        if (result)
+            return true;
+    }
+}
+
+/**
+ * Check if there is draw game.
+ *
+ * @return true if game is draw
+ */
+Game.prototype.hasDraw = function ()
+{
+    for (var r = 0; r < 3; ++r)
+        for (var c = 0; c < 3; ++c)
+            if (this.grid[r][c] === '.')
+                return false;
+
+    return true;
+}
+
+function onNames(server, channel, list)
+{
+    var id = Game.id(server, channel);
+    var game = Game.requests[id];
+
+    // Names can come from any other plugin/event.
+    if (!game)
+        return;
+
+    // Not a valid target? destroy the game.
+    if (list.indexOf(game.target) < 0)
+        server.message(channel, Util.format(Plugin.format.invalid,
+            Game.keywords(server, channel, game.origin)));
+    else {
+        Game.map[id] = game;
+        game.show();
+    }
+
+    delete Game.requests[id];
+}
+
+function onCommand(server, origin, channel, message)
+{
+    var target = message.trim();
+    var nickname = Util.splituser(origin);
+
+    if (Game.exists(server, channel))
+        server.message(channel, Util.format(Plugin.format.running, Game.keywords(server, channel, origin)));
+    else if (target === "" || target === nickname || target === server.info().nickname)
+        server.message(channel, Util.format(Plugin.format.invalid, Game.keywords(server, channel, origin)));
+    else
+        Game.postpone(server, channel, origin, message);
+}
+
+function onMessage(server, origin, channel, message)
+{
+    var nickname = Util.splituser(origin);
+    var game = Game.find(server, channel);
+
+    if (!game || !game.isTurn(nickname))
+        return;
+
+    var match = /([abc]) ([123])/.exec(message);
+
+    if (!match)
+        return;
+
+    if (game.place(match[1], match[2], origin))
+        game.show();
+    if (game.hasWinner() || game.hasDraw())
+        Game.remove(server, channel);
+}
+
+function onKick(server, origin, channel, target)
+{
+    Game.clear(server, target, channel);
+}
+
+function onPart(server, origin, channel)
+{
+    Game.clear(server, origin, channel);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/tictactoe/tictactoe.md	Tue Mar 13 13:51:17 2018 +0100
@@ -0,0 +1,97 @@
+---
+title: "Tictactoe plugin"
+header: "Tictactoe plugin"
+guide: yes
+---
+
+This plugin let you play tictactoe over IRC.
+
+Warning: this plugin is verbose.
+
+## Installation
+
+The plugin **tictactoe** is distributed with irccd. To enable it add the following
+to your `plugins` section:
+
+```ini
+[plugins]
+tictactoe = ""
+```
+
+## Usage
+
+Execute **tictactoe** plugin with the target opponent nickname. Then each player
+send a message in the form **x y** where x targets the column and y the row.
+
+To verify target opponent, this plugins first requests the names on the channel
+to ensures a valid player.
+
+If one of the players leaves the channel (either by kick or part) the game is
+aborted.
+
+```nohighlight
+markand: !tictactoe francis
+irccd:   a b c
+irccd: 1 . . .
+irccd: 2 . . .
+irccd: 3 . . .
+irccd: markand, it's your turn
+```
+
+And then, placing tokens.
+
+```nohighlight
+20:27 < markand> a 1
+20:27 < irccd>   a b c
+20:27 < irccd> 1 x . .
+20:27 < irccd> 2 . . .
+20:27 < irccd> 3 . . .
+20:27 < irccd> francis, it's your turn
+20:27 <@francis> c 1
+20:27 < irccd>   a b c
+20:27 < irccd> 1 x . o
+20:27 < irccd> 2 . . .
+20:27 < irccd> 3 . . .
+20:27 < irccd> markand, it's your turn
+20:27 < markand> a 2
+20:27 < irccd>   a b c
+20:27 < irccd> 1 x . o
+20:27 < irccd> 2 x . .
+20:27 < irccd> 3 . . .
+20:27 < irccd> francis, it's your turn
+20:27 <@francis> c 3
+20:27 < irccd>   a b c
+20:27 < irccd> 1 x . o
+20:27 < irccd> 2 x . .
+20:27 < irccd> 3 . . o
+20:27 < irccd> markand, it's your turn
+20:27 < markand> a 3
+20:27 < irccd>   a b c
+20:27 < irccd> 1 x . o
+20:27 < irccd> 2 x . .
+20:27 < irccd> 3 x . o
+20:27 < irccd> francis, it's your turn
+20:27 < irccd> markand, congratulations, you won!
+```
+
+## Formats
+
+The **tictactoe** plugin supports the following formats in `[format.tictactoe]`
+section:
+
+  - **draw**: when the game ended with no winner,
+  - **invalid**: the opponent does not exist or is not valid,
+  - **running**: the game is already running,
+  - **turn**: message sent when current player change,
+  - **used**: the cell requested is already used,
+  - **win**: game ended with a winner.
+
+### Keywords supported
+
+The following keywords are supported:
+
+| Format  | Keywords                                   | Notes       |
+|---------|--------------------------------------------|-------------|
+| (any)   | channel, command, nickname, plugin, server | all formats |
+| invalid | origin                                     |             |
+| running | origin                                     |             |
--- a/tests/src/plugins/CMakeLists.txt	Wed Mar 07 21:00:56 2018 +0100
+++ b/tests/src/plugins/CMakeLists.txt	Tue Mar 13 13:51:17 2018 +0100
@@ -23,3 +23,4 @@
 add_subdirectory(joke)
 add_subdirectory(logger)
 add_subdirectory(plugin)
+add_subdirectory(tictactoe)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/plugins/tictactoe/CMakeLists.txt	Tue Mar 13 13:51:17 2018 +0100
@@ -0,0 +1,27 @@
+#
+# CMakeLists.txt -- CMake build system for irccd
+#
+# Copyright (c) 2013-2018 David Demelier <markand@malikania.fr>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+#
+
+irccd_define_test(
+    NAME plugin-tictactoe
+    SOURCES main.cpp
+    LIBRARIES libirccd
+    FLAGS
+        PLUGIN_NAME="tictactoe"
+        PLUGIN_PATH="${CMAKE_SOURCE_DIR}/plugins/tictactoe/tictactoe.js"
+)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/src/plugins/tictactoe/main.cpp	Tue Mar 13 13:51:17 2018 +0100
@@ -0,0 +1,257 @@
+/*
+ * main.cpp -- test plugin plugin
+ *
+ * Copyright (c) 2013-2018 David Demelier <markand@malikania.fr>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#define BOOST_TEST_MODULE "Plugin tictactoe"
+#include <boost/test/unit_test.hpp>
+
+#include <irccd/string_util.hpp>
+
+#include <irccd/daemon/irccd.hpp>
+#include <irccd/daemon/server.hpp>
+#include <irccd/daemon/service/plugin_service.hpp>
+
+#include <irccd/test/plugin_test.hpp>
+
+namespace irccd {
+
+class test_fixture : public plugin_test {
+public:
+    test_fixture()
+        : plugin_test(PLUGIN_NAME, PLUGIN_PATH)
+    {
+        plugin_->set_formats({
+            { "draw",       "draw=#{channel}:#{command}:#{nickname}:#{plugin}:#{server}"                },
+            { "invalid",    "invalid=#{channel}:#{command}:#{nickname}:#{origin}:#{plugin}:#{server}"   },
+            { "running",    "running=#{channel}:#{command}:#{nickname}:#{origin}:#{plugin}:#{server}"   },
+            { "turn",       "turn=#{channel}:#{command}:#{nickname}:#{plugin}:#{server}"                },
+            { "used",       "used=#{channel}:#{command}:#{nickname}:#{origin}:#{plugin}:#{server}"      },
+            { "win",        "win=#{channel}:#{command}:#{nickname}:#{plugin}:#{server}"                 }
+        });
+    }
+
+    auto next_players() const
+    {
+        if (server_->cqueue().size() == 0)
+            throw std::runtime_error("no message");
+
+        const auto cmd = server_->cqueue().back();
+        const auto list = string_util::split(cmd["message"].get<std::string>(), ":");
+
+        BOOST_TEST(list.size() == 5U);
+        BOOST_TEST(list[0] == "turn=#tictactoe");
+        BOOST_TEST(list[1] == "!tictactoe");
+        BOOST_TEST(list[3] == "tictactoe");
+        BOOST_TEST(list[4] == "test");
+
+        return list[2] == "a" ? std::make_pair("a", "b") : std::make_pair("b", "a");
+    }
+
+    auto start()
+    {
+        plugin_->on_command(irccd_, {server_, "a!a@localhost", "#tictactoe", "b"});
+        plugin_->on_names(irccd_, {server_, "#tictactoe", {"a", "b"}});
+
+        return next_players();
+    }
+
+    /**
+     * Helper to place several tokens on the board and automatically toggling
+     * players.
+     *
+     * This will start the game from "a" with target opponent "b".
+     *
+     */
+    void run(const std::initializer_list<std::string>& points)
+    {
+        auto players = start();
+
+        for (const auto& p : points) {
+            server_->cqueue().clear();
+            plugin_->on_message(irccd_, {server_, players.first, "#tictactoe", p});
+            players = next_players();
+        }
+    }
+};
+
+BOOST_FIXTURE_TEST_SUITE(test_fixture_suite, test_fixture)
+
+BOOST_AUTO_TEST_CASE(win)
+{
+    run({"a 1", "b 1", "a 2", "b 2"});
+
+    const auto players = next_players();
+
+    plugin_->on_message(irccd_, {server_, players.first, "#tictactoe", "a 3"});
+
+    const auto cmd = server_->cqueue().back();
+
+    BOOST_TEST(cmd.is_object());
+    BOOST_TEST(cmd["command"].get<std::string>() == "message");
+    BOOST_TEST(cmd["target"].get<std::string>() == "#tictactoe");
+
+    const auto parts = string_util::split(cmd["message"].get<std::string>(), ":");
+
+    BOOST_TEST(parts.size() == 5U);
+    BOOST_TEST(parts[0] == "win=#tictactoe");
+    BOOST_TEST(parts[1] == "!tictactoe");
+    BOOST_TEST(parts[2] == players.first);
+    BOOST_TEST(parts[3] == "tictactoe");
+    BOOST_TEST(parts[4] == "test");
+}
+
+BOOST_AUTO_TEST_CASE(draw)
+{
+    /*
+     *   a b c
+     * 1 o x o
+     * 2 o x x
+     * 3 x o x
+     */
+    run({ "b 2", "c 1", "c 3", "b 3", "c 2", "a 2", "a 3", "a 1" });
+
+    const auto players = next_players();
+
+    plugin_->on_message(irccd_, {server_, players.first, "#tictactoe", "b 1"});
+
+    const auto cmd = server_->cqueue().back();
+
+    BOOST_TEST(cmd.is_object());
+    BOOST_TEST(cmd["command"].get<std::string>() == "message");
+    BOOST_TEST(cmd["target"].get<std::string>() == "#tictactoe");
+
+    const auto parts = string_util::split(cmd["message"].get<std::string>(), ":");
+
+    BOOST_TEST(parts.size() == 5U);
+    BOOST_TEST(parts[0] == "draw=#tictactoe");
+    BOOST_TEST(parts[1] == "!tictactoe");
+    BOOST_TEST(parts[2] == players.first);
+    BOOST_TEST(parts[3] == "tictactoe");
+    BOOST_TEST(parts[4] == "test");
+}
+
+BOOST_AUTO_TEST_CASE(used)
+{
+    auto players = start();
+
+    plugin_->on_message(irccd_, {server_, players.first, "#tictactoe", "a 1"});
+    plugin_->on_message(irccd_, {server_, players.second, "#tictactoe", "a 1"});
+
+    const auto cmd = server_->cqueue().back();
+
+    BOOST_TEST(cmd.is_object());
+    BOOST_TEST(cmd["command"].get<std::string>() == "message");
+    BOOST_TEST(cmd["target"].get<std::string>() == "#tictactoe");
+
+    const auto parts = string_util::split(cmd["message"].get<std::string>(), ":");
+
+    BOOST_TEST(parts[0] == "used=#tictactoe");
+    BOOST_TEST(parts[1] == "!tictactoe");
+    BOOST_TEST(parts[2] == players.second);
+    BOOST_TEST(parts[3] == players.second);
+    BOOST_TEST(parts[4] == "tictactoe");
+    BOOST_TEST(parts[5] == "test");
+}
+
+BOOST_AUTO_TEST_CASE(invalid)
+{
+    nlohmann::json cmd;
+
+    // empty name (no names)
+    plugin_->on_command(irccd_, {server_, "jean", "#tictactoe", ""});
+    cmd = server_->cqueue().back();
+
+    BOOST_TEST(cmd["command"].get<std::string>() == "message");
+    BOOST_TEST(cmd["target"].get<std::string>() == "#tictactoe");
+    BOOST_TEST(cmd["message"].get<std::string>() == "invalid=#tictactoe:!tictactoe:jean:jean:tictactoe:test");
+
+    // bot name (no names)
+    plugin_->on_command(irccd_, {server_, "jean", "#tictactoe", "irccd"});
+    cmd = server_->cqueue().back();
+
+    BOOST_TEST(cmd["command"].get<std::string>() == "message");
+    BOOST_TEST(cmd["target"].get<std::string>() == "#tictactoe");
+    BOOST_TEST(cmd["message"].get<std::string>() == "invalid=#tictactoe:!tictactoe:jean:jean:tictactoe:test");
+
+    // target is origin (no names)
+    plugin_->on_command(irccd_, {server_, server_->nickname(), "#tictactoe", server_->nickname()});
+    cmd = server_->cqueue().back();
+
+    BOOST_TEST(cmd["command"].get<std::string>() == "message");
+    BOOST_TEST(cmd["target"].get<std::string>() == "#tictactoe");
+    BOOST_TEST(cmd["message"].get<std::string>() == "invalid=#tictactoe:!tictactoe:irccd:irccd:tictactoe:test");
+
+    // not existing (names)
+    plugin_->on_command(irccd_, {server_, server_->nickname(), "#tictactoe", server_->nickname()});
+    plugin_->on_names(irccd_, {server_, "#tictactoe", {"a", "b", "c"}});
+    cmd = server_->cqueue().back();
+
+    BOOST_TEST(cmd["command"].get<std::string>() == "message");
+    BOOST_TEST(cmd["target"].get<std::string>() == "#tictactoe");
+    BOOST_TEST(cmd["message"].get<std::string>() == "invalid=#tictactoe:!tictactoe:irccd:irccd:tictactoe:test");
+}
+
+BOOST_AUTO_TEST_CASE(random)
+{
+    /*
+     * Ensure that the first player is not always the originator, start the game
+     * for at most 1'000'000 times to avoid forever loop.
+     */
+    unsigned count = 0;
+    bool a = false;
+    bool b = false;
+
+    // Last player turn is the winner.
+    while (!a && !b && count++ < 1000000U) {
+        run({"a 1", "b 1", "a 2", "b 2"});
+
+        const auto players = next_players();
+
+        if (players.first == std::string("a"))
+            a = true;
+        else
+            b = true;
+
+        plugin_->on_message(irccd_, {server_, players.first, "#tictactoe", "a 3"});
+    }
+}
+
+BOOST_AUTO_TEST_CASE(kick)
+{
+    auto players = start();
+
+    server_->cqueue().clear();
+    plugin_->on_kick(irccd_, {server_, "kefka", "#tictactoe", players.first, ""});
+    plugin_->on_message(irccd_, {server_, players.first, "#tictactoe", "a 1"});
+
+    BOOST_TEST(server_->cqueue().empty());
+}
+
+BOOST_AUTO_TEST_CASE(part)
+{
+    auto players = start();
+
+    server_->cqueue().clear();
+    plugin_->on_part(irccd_, {server_, players.first, "#tictactoe", ""});
+    plugin_->on_message(irccd_, {server_, players.first, "#tictactoe", "a 1"});
+
+    BOOST_TEST(server_->cqueue().empty());
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+} // !irccd