-
Notifications
You must be signed in to change notification settings - Fork 716
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* * Add base class for Python bot development (#98) * Add example for a TicTacToe bot * python bot: check that player_id is one of 'actionPlayers' before playing * Add a bunch of unit-tests for the python client * Add instructions for coverage of python client unit-tests (90%) * pylint options * fix pylint warnings
- Loading branch information
1 parent
a7134a5
commit 99b9844
Showing
5 changed files
with
350 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
*.pyc | ||
.cache | ||
.coverage | ||
htmlcov |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
# | ||
# Copyright 2018 The boardgame.io Authors | ||
# | ||
# Use of this source code is governed by a MIT-style | ||
# license that can be found in the LICENSE file or at | ||
# https://opensource.org/licenses/MIT. | ||
# | ||
# pylint: disable=invalid-name,import-error,no-self-use | ||
|
||
""" | ||
Boardgame.io python client. | ||
""" | ||
|
||
import logging | ||
import socketIO_client as io | ||
|
||
class Namespace(io.BaseNamespace): | ||
""" | ||
SocketIO namespace providing handlers for events | ||
of the connection with the boardgame.io server. | ||
""" | ||
log = logging.getLogger('client.namespace') | ||
|
||
def __init__(self, *args): | ||
io.BaseNamespace.__init__(self, *args) | ||
self.bot = None | ||
self.previous_state_id = None | ||
self.actions = [] | ||
|
||
def set_bot_info(self, bot): | ||
""" | ||
Provides access to the Bot class that owns the connection. | ||
FIXME: is made necessary since socketio does not provide (yet) a way | ||
to pass extra arguments to the ctor of the namespace at creation. | ||
""" | ||
self.bot = bot | ||
return self | ||
|
||
def on_connect(self): | ||
""" Handle connection event. """ | ||
self.log.info('connected') # to game <%s>' % self.bot.game_id) | ||
|
||
def on_disconnect(self): | ||
""" Handle disconnection event. """ | ||
self.log.info('disconnected') | ||
def on_reconnect(self): | ||
""" Handle reconnection event. """ | ||
self.log.info('reconnected') | ||
|
||
def on_sync(self, *args): | ||
""" Handle serve 'sync' event. """ | ||
game_id = args[0] | ||
state = args[1] | ||
state_id = state['_stateID'] | ||
ctx = state['ctx'] | ||
|
||
# is it my game and my turn to play? | ||
if game_id == self.bot.game_id: | ||
if not self.previous_state_id or state_id >= self.previous_state_id: | ||
|
||
self.previous_state_id = state_id | ||
self.log.debug('state = %s', str(state)) | ||
G = state['G'] | ||
|
||
if 'gameover' in ctx: | ||
# game over | ||
self.bot.gameover(G, ctx) | ||
|
||
elif self.bot.player_id in ctx['actionPlayers']: | ||
self.log.info('phase is %s', ctx['phase']) | ||
if not self.actions: | ||
# plan next actions | ||
self.actions = self.bot.think(G, ctx) | ||
if not isinstance(self.actions, list): | ||
self.actions = [self.actions] | ||
if self.actions: | ||
# pop next action | ||
action = self.actions.pop(0) | ||
self.log.info('sent action: %s', action['payload']) | ||
self.emit('action', action, state_id, game_id, | ||
self.bot.player_id) | ||
|
||
|
||
class Bot(object): | ||
""" | ||
Base class for boardgame.io bot. | ||
""" | ||
log = logging.getLogger('client.bot') | ||
|
||
def __init__(self, server='localhost', port='8000', | ||
options=None): | ||
""" | ||
Connect to server with given game name, id and player id. | ||
Request initial synchronization. | ||
""" | ||
opts = {'game_name' : 'default', | ||
'game_id' : 'default', | ||
'player_id' : '1', | ||
'num_players': 2} | ||
opts.update(options or {}) | ||
self.game_id = opts['game_name'] + ':' + opts['game_id'] | ||
self.player_id = opts['player_id'] | ||
self.num_players = opts['num_players'] | ||
|
||
# open websocket | ||
socket = io.SocketIO(server, port, wait_for_connection=False) | ||
self.io_namespace = socket.define(Namespace, '/'+opts['game_name']) | ||
self.io_namespace.set_bot_info(self) | ||
self.socket = socket | ||
|
||
# request initial sync | ||
self.io_namespace.emit('sync', self.game_id, self.player_id, self.num_players) | ||
|
||
def _create_action(self, action, typ, args=None): | ||
if not args: | ||
args = [] | ||
return { | ||
'type': action, | ||
'payload': { | ||
'type': typ, | ||
'args': args, | ||
'playerID': self.player_id | ||
} | ||
} | ||
|
||
def make_move(self, typ, *args): | ||
""" Create MAKE_MOVE action. """ | ||
return self._create_action('MAKE_MOVE', typ, list(args)) | ||
|
||
def game_event(self, typ): | ||
""" Create GAME_EVENT action. """ | ||
return self._create_action('GAME_EVENT', typ) | ||
|
||
def listen(self, timeout=1): | ||
""" | ||
Listen and handle server events: when it is the bot's turn to play, | ||
method 'think' will be called with the game state and context. | ||
Return after 'timeout' seconds if no events. | ||
""" | ||
self.socket.wait(seconds=timeout) | ||
|
||
def think(self, _G, _ctx): | ||
""" | ||
To be overridden by the user. | ||
Shall return a list of actions, instantiated with make_move(). | ||
""" | ||
assert False | ||
|
||
def gameover(self, _G, _ctx): | ||
""" | ||
To be overridden by the user. | ||
Shall handle game over. | ||
""" | ||
assert False |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
../../boardgameio.py |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
#!/usr/bin/python | ||
# | ||
# Copyright 2018 The boardgame.io Authors | ||
# | ||
# Use of this source code is governed by a MIT-style | ||
# license that can be found in the LICENSE file or at | ||
# https://opensource.org/licenses/MIT. | ||
# | ||
# pylint: disable=invalid-name,multiple-imports,global-statement | ||
|
||
# To play against this bot, start the tictactoe server from http://boardgame.io/#/multiplayer | ||
# and start the bot with: | ||
# $ python tictactoebot.py | ||
# (will play player '1' by default) | ||
|
||
""" | ||
Boardgame.io python client example: starts a bot with player id '0' | ||
that plays randomly against the other player '1'. | ||
""" | ||
|
||
import signal, random, logging | ||
from boardgameio import Bot | ||
|
||
class TicTacToeBot(Bot): | ||
""" | ||
Example of use of base class boardgameio.Bot: | ||
- the bot connects to the multiplayer server at construction | ||
- each time it is the bot's turn to play, method 'think' is called | ||
- when game is over, method 'gameover' is called. | ||
""" | ||
log = logging.getLogger('tictactoebot') | ||
|
||
def __init__(self): | ||
Bot.__init__(self, server='localhost', port=8000, | ||
options={'game_name': 'default', | ||
'num_players': 2, | ||
'player_id': '1'}) | ||
|
||
def think(self, G, _ctx): | ||
""" Called when it is this bot's turn to play. """ | ||
cells = G['cells'] | ||
# choose a random empty cell | ||
idx = -1 | ||
while True and None in cells: | ||
idx = random.randint(0, len(cells)-1) | ||
if not cells[idx]: | ||
break | ||
self.log.debug('cell chosen: %d', idx) | ||
return self.make_move('clickCell', idx) | ||
|
||
def gameover(self, _G, ctx): | ||
""" Called when game is over. """ | ||
self.log.info('winner is %s', ctx['gameover']) | ||
|
||
|
||
running = False | ||
log = logging.getLogger('main') | ||
logging.basicConfig(level=logging.INFO) | ||
|
||
def main(): | ||
""" Start bot and listen continuously for events. """ | ||
log.info('starting bot... (Ctrl-C to stop)') | ||
client = TicTacToeBot() | ||
global running | ||
running = True | ||
while running: | ||
client.listen() | ||
log.info('stopped.') | ||
|
||
def stop(_signum, _frame): | ||
""" Stop program. """ | ||
log.info('stopping...') | ||
global running | ||
running = False | ||
|
||
# start process | ||
if __name__ == '__main__': | ||
signal.signal(signal.SIGINT, stop) | ||
signal.signal(signal.SIGTERM, stop) | ||
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
# | ||
# Copyright 2018 The boardgame.io Authors | ||
# | ||
# Use of this source code is governed by a MIT-style | ||
# license that can be found in the LICENSE file or at | ||
# https://opensource.org/licenses/MIT. | ||
# | ||
# pylint: disable=invalid-name,import-error,no-self-use,missing-docstring | ||
|
||
# To run unit-tests: | ||
# $ python -m unittest discover | ||
# For coverage report: | ||
# $ coverage run --source=boardgameio.py test_boardgameio.py | ||
# $ coverage report | ||
|
||
import unittest | ||
import logging | ||
import mock | ||
import socketIO_client as io | ||
from boardgameio import Namespace, Bot | ||
|
||
|
||
logging.basicConfig(level=logging.DEBUG) | ||
|
||
|
||
class TestNamespace(unittest.TestCase): | ||
|
||
def setUp(self): | ||
self.game_state = {'_stateID': 1234, 'G': {}, 'ctx': { | ||
'actionPlayers': ['1'], | ||
'phase': 'phase0' | ||
}} | ||
self.resulting_move = {'payload': 'action0'} | ||
# mock Bot instance | ||
self.botmock = mock.Mock(spec=Bot)() | ||
self.botmock.game_id = 'game0' | ||
self.botmock.player_id = self.game_state['ctx']['actionPlayers'][0] | ||
self.botmock.think.return_value = self.resulting_move | ||
# mock socket | ||
self.sockmock = mock.Mock(spec=io.SocketIO)() | ||
# instantiate SUT | ||
self.sut = Namespace(self.sockmock, 'default').set_bot_info(self.botmock) | ||
self.sut.emit = mock.MagicMock(name='emit') | ||
|
||
def test_on_sync_shall_call_think(self): | ||
# call Namespace.on_sync() | ||
self.sut.on_sync(self.botmock.game_id, self.game_state) | ||
self.botmock.think.assert_called_once_with(self.game_state['G'], self.game_state['ctx']) | ||
self.sut.emit.assert_called_once_with('action', self.resulting_move, | ||
self.game_state['_stateID'], | ||
self.botmock.game_id, self.botmock.player_id) | ||
|
||
def test_on_sync_shall_not_call_think_if_game_id_is_different(self): | ||
# call on_sync with another game id | ||
self.sut.on_sync('other-game', self.game_state) | ||
self.botmock.think.assert_not_called() | ||
self.sut.emit.assert_not_called() | ||
|
||
def test_on_sync_shall_not_call_think_if_player_id_is_not_active(self): | ||
# change active players in game state | ||
self.game_state['ctx']['actionPlayers'] = ['0', '2'] | ||
# call on_sync with bot game id | ||
self.sut.on_sync(self.botmock.game_id, self.game_state) | ||
self.botmock.think.assert_not_called() | ||
self.sut.emit.assert_not_called() | ||
|
||
def test_on_sync_shall_call_gameover_if_game_is_over(self): | ||
# activate gameover in game state | ||
self.game_state['ctx']['gameover'] = ['0'] | ||
# call on_sync with bot game id | ||
self.sut.on_sync(self.botmock.game_id, self.game_state) | ||
self.botmock.gameover.assert_called_once_with(self.game_state['G'], self.game_state['ctx']) | ||
self.sut.emit.assert_not_called() | ||
|
||
|
||
class TestBot(unittest.TestCase): | ||
|
||
def setUp(self): | ||
self.game_state = {'_stateID': 1234, 'G': {}, 'ctx': { | ||
'actionPlayers': ['1'], | ||
'phase': 'phase0' | ||
}} | ||
self.resulting_move = {'payload': 'action0'} | ||
# mock socket | ||
io.SocketIO = mock.Mock(spec=io.SocketIO) | ||
# instantiate SUT | ||
self.sut = Bot() | ||
self.sut.think = mock.MagicMock(name='think') | ||
self.sut.gameover = mock.MagicMock(name='gameover') | ||
|
||
def test_make_move_shall_return_move_action(self): | ||
self.assertEqual(self.sut.make_move('type', 'foo', 'bar'), | ||
{'type': 'MAKE_MOVE', | ||
'payload': { | ||
'type': 'type', | ||
'args': ['foo', 'bar'], | ||
'playerID': self.sut.player_id | ||
}}) | ||
|
||
def test_game_event_shall_return_event_action(self): | ||
self.assertEqual(self.sut.game_event('foobar'), | ||
{'type': 'GAME_EVENT', | ||
'payload': { | ||
'type': 'foobar', | ||
'args': [], | ||
'playerID': self.sut.player_id} | ||
}) | ||
|
||
|
||
if __name__ == '__main__': | ||
unittest.main() |