Skip to content

Commit

Permalink
Python Bot base class (#98) (#195)
Browse files Browse the repository at this point in the history
* * 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
francoijs authored and nicolodavis committed Jun 8, 2018
1 parent a7134a5 commit 99b9844
Show file tree
Hide file tree
Showing 5 changed files with 350 additions and 0 deletions.
4 changes: 4 additions & 0 deletions python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
*.pyc
.cache
.coverage
htmlcov
154 changes: 154 additions & 0 deletions python/boardgameio.py
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
1 change: 1 addition & 0 deletions python/examples/tic-tac-toe/boardgameio.py
80 changes: 80 additions & 0 deletions python/examples/tic-tac-toe/tictactoebot.py
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()
111 changes: 111 additions & 0 deletions python/test_boardgameio.py
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()

0 comments on commit 99b9844

Please sign in to comment.