Skip to content

Commit 99b9844

Browse files
francoijsnicolodavis
authored andcommitted
Python Bot base class (#98) (#195)
* * 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
1 parent a7134a5 commit 99b9844

5 files changed

Lines changed: 350 additions & 0 deletions

File tree

python/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*.pyc
2+
.cache
3+
.coverage
4+
htmlcov

python/boardgameio.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#
2+
# Copyright 2018 The boardgame.io Authors
3+
#
4+
# Use of this source code is governed by a MIT-style
5+
# license that can be found in the LICENSE file or at
6+
# https://opensource.org/licenses/MIT.
7+
#
8+
# pylint: disable=invalid-name,import-error,no-self-use
9+
10+
"""
11+
Boardgame.io python client.
12+
"""
13+
14+
import logging
15+
import socketIO_client as io
16+
17+
class Namespace(io.BaseNamespace):
18+
"""
19+
SocketIO namespace providing handlers for events
20+
of the connection with the boardgame.io server.
21+
"""
22+
log = logging.getLogger('client.namespace')
23+
24+
def __init__(self, *args):
25+
io.BaseNamespace.__init__(self, *args)
26+
self.bot = None
27+
self.previous_state_id = None
28+
self.actions = []
29+
30+
def set_bot_info(self, bot):
31+
"""
32+
Provides access to the Bot class that owns the connection.
33+
FIXME: is made necessary since socketio does not provide (yet) a way
34+
to pass extra arguments to the ctor of the namespace at creation.
35+
"""
36+
self.bot = bot
37+
return self
38+
39+
def on_connect(self):
40+
""" Handle connection event. """
41+
self.log.info('connected') # to game <%s>' % self.bot.game_id)
42+
43+
def on_disconnect(self):
44+
""" Handle disconnection event. """
45+
self.log.info('disconnected')
46+
def on_reconnect(self):
47+
""" Handle reconnection event. """
48+
self.log.info('reconnected')
49+
50+
def on_sync(self, *args):
51+
""" Handle serve 'sync' event. """
52+
game_id = args[0]
53+
state = args[1]
54+
state_id = state['_stateID']
55+
ctx = state['ctx']
56+
57+
# is it my game and my turn to play?
58+
if game_id == self.bot.game_id:
59+
if not self.previous_state_id or state_id >= self.previous_state_id:
60+
61+
self.previous_state_id = state_id
62+
self.log.debug('state = %s', str(state))
63+
G = state['G']
64+
65+
if 'gameover' in ctx:
66+
# game over
67+
self.bot.gameover(G, ctx)
68+
69+
elif self.bot.player_id in ctx['actionPlayers']:
70+
self.log.info('phase is %s', ctx['phase'])
71+
if not self.actions:
72+
# plan next actions
73+
self.actions = self.bot.think(G, ctx)
74+
if not isinstance(self.actions, list):
75+
self.actions = [self.actions]
76+
if self.actions:
77+
# pop next action
78+
action = self.actions.pop(0)
79+
self.log.info('sent action: %s', action['payload'])
80+
self.emit('action', action, state_id, game_id,
81+
self.bot.player_id)
82+
83+
84+
class Bot(object):
85+
"""
86+
Base class for boardgame.io bot.
87+
"""
88+
log = logging.getLogger('client.bot')
89+
90+
def __init__(self, server='localhost', port='8000',
91+
options=None):
92+
"""
93+
Connect to server with given game name, id and player id.
94+
Request initial synchronization.
95+
"""
96+
opts = {'game_name' : 'default',
97+
'game_id' : 'default',
98+
'player_id' : '1',
99+
'num_players': 2}
100+
opts.update(options or {})
101+
self.game_id = opts['game_name'] + ':' + opts['game_id']
102+
self.player_id = opts['player_id']
103+
self.num_players = opts['num_players']
104+
105+
# open websocket
106+
socket = io.SocketIO(server, port, wait_for_connection=False)
107+
self.io_namespace = socket.define(Namespace, '/'+opts['game_name'])
108+
self.io_namespace.set_bot_info(self)
109+
self.socket = socket
110+
111+
# request initial sync
112+
self.io_namespace.emit('sync', self.game_id, self.player_id, self.num_players)
113+
114+
def _create_action(self, action, typ, args=None):
115+
if not args:
116+
args = []
117+
return {
118+
'type': action,
119+
'payload': {
120+
'type': typ,
121+
'args': args,
122+
'playerID': self.player_id
123+
}
124+
}
125+
126+
def make_move(self, typ, *args):
127+
""" Create MAKE_MOVE action. """
128+
return self._create_action('MAKE_MOVE', typ, list(args))
129+
130+
def game_event(self, typ):
131+
""" Create GAME_EVENT action. """
132+
return self._create_action('GAME_EVENT', typ)
133+
134+
def listen(self, timeout=1):
135+
"""
136+
Listen and handle server events: when it is the bot's turn to play,
137+
method 'think' will be called with the game state and context.
138+
Return after 'timeout' seconds if no events.
139+
"""
140+
self.socket.wait(seconds=timeout)
141+
142+
def think(self, _G, _ctx):
143+
"""
144+
To be overridden by the user.
145+
Shall return a list of actions, instantiated with make_move().
146+
"""
147+
assert False
148+
149+
def gameover(self, _G, _ctx):
150+
"""
151+
To be overridden by the user.
152+
Shall handle game over.
153+
"""
154+
assert False
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../boardgameio.py
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/python
2+
#
3+
# Copyright 2018 The boardgame.io Authors
4+
#
5+
# Use of this source code is governed by a MIT-style
6+
# license that can be found in the LICENSE file or at
7+
# https://opensource.org/licenses/MIT.
8+
#
9+
# pylint: disable=invalid-name,multiple-imports,global-statement
10+
11+
# To play against this bot, start the tictactoe server from http://boardgame.io/#/multiplayer
12+
# and start the bot with:
13+
# $ python tictactoebot.py
14+
# (will play player '1' by default)
15+
16+
"""
17+
Boardgame.io python client example: starts a bot with player id '0'
18+
that plays randomly against the other player '1'.
19+
"""
20+
21+
import signal, random, logging
22+
from boardgameio import Bot
23+
24+
class TicTacToeBot(Bot):
25+
"""
26+
Example of use of base class boardgameio.Bot:
27+
- the bot connects to the multiplayer server at construction
28+
- each time it is the bot's turn to play, method 'think' is called
29+
- when game is over, method 'gameover' is called.
30+
"""
31+
log = logging.getLogger('tictactoebot')
32+
33+
def __init__(self):
34+
Bot.__init__(self, server='localhost', port=8000,
35+
options={'game_name': 'default',
36+
'num_players': 2,
37+
'player_id': '1'})
38+
39+
def think(self, G, _ctx):
40+
""" Called when it is this bot's turn to play. """
41+
cells = G['cells']
42+
# choose a random empty cell
43+
idx = -1
44+
while True and None in cells:
45+
idx = random.randint(0, len(cells)-1)
46+
if not cells[idx]:
47+
break
48+
self.log.debug('cell chosen: %d', idx)
49+
return self.make_move('clickCell', idx)
50+
51+
def gameover(self, _G, ctx):
52+
""" Called when game is over. """
53+
self.log.info('winner is %s', ctx['gameover'])
54+
55+
56+
running = False
57+
log = logging.getLogger('main')
58+
logging.basicConfig(level=logging.INFO)
59+
60+
def main():
61+
""" Start bot and listen continuously for events. """
62+
log.info('starting bot... (Ctrl-C to stop)')
63+
client = TicTacToeBot()
64+
global running
65+
running = True
66+
while running:
67+
client.listen()
68+
log.info('stopped.')
69+
70+
def stop(_signum, _frame):
71+
""" Stop program. """
72+
log.info('stopping...')
73+
global running
74+
running = False
75+
76+
# start process
77+
if __name__ == '__main__':
78+
signal.signal(signal.SIGINT, stop)
79+
signal.signal(signal.SIGTERM, stop)
80+
main()

python/test_boardgameio.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#
2+
# Copyright 2018 The boardgame.io Authors
3+
#
4+
# Use of this source code is governed by a MIT-style
5+
# license that can be found in the LICENSE file or at
6+
# https://opensource.org/licenses/MIT.
7+
#
8+
# pylint: disable=invalid-name,import-error,no-self-use,missing-docstring
9+
10+
# To run unit-tests:
11+
# $ python -m unittest discover
12+
# For coverage report:
13+
# $ coverage run --source=boardgameio.py test_boardgameio.py
14+
# $ coverage report
15+
16+
import unittest
17+
import logging
18+
import mock
19+
import socketIO_client as io
20+
from boardgameio import Namespace, Bot
21+
22+
23+
logging.basicConfig(level=logging.DEBUG)
24+
25+
26+
class TestNamespace(unittest.TestCase):
27+
28+
def setUp(self):
29+
self.game_state = {'_stateID': 1234, 'G': {}, 'ctx': {
30+
'actionPlayers': ['1'],
31+
'phase': 'phase0'
32+
}}
33+
self.resulting_move = {'payload': 'action0'}
34+
# mock Bot instance
35+
self.botmock = mock.Mock(spec=Bot)()
36+
self.botmock.game_id = 'game0'
37+
self.botmock.player_id = self.game_state['ctx']['actionPlayers'][0]
38+
self.botmock.think.return_value = self.resulting_move
39+
# mock socket
40+
self.sockmock = mock.Mock(spec=io.SocketIO)()
41+
# instantiate SUT
42+
self.sut = Namespace(self.sockmock, 'default').set_bot_info(self.botmock)
43+
self.sut.emit = mock.MagicMock(name='emit')
44+
45+
def test_on_sync_shall_call_think(self):
46+
# call Namespace.on_sync()
47+
self.sut.on_sync(self.botmock.game_id, self.game_state)
48+
self.botmock.think.assert_called_once_with(self.game_state['G'], self.game_state['ctx'])
49+
self.sut.emit.assert_called_once_with('action', self.resulting_move,
50+
self.game_state['_stateID'],
51+
self.botmock.game_id, self.botmock.player_id)
52+
53+
def test_on_sync_shall_not_call_think_if_game_id_is_different(self):
54+
# call on_sync with another game id
55+
self.sut.on_sync('other-game', self.game_state)
56+
self.botmock.think.assert_not_called()
57+
self.sut.emit.assert_not_called()
58+
59+
def test_on_sync_shall_not_call_think_if_player_id_is_not_active(self):
60+
# change active players in game state
61+
self.game_state['ctx']['actionPlayers'] = ['0', '2']
62+
# call on_sync with bot game id
63+
self.sut.on_sync(self.botmock.game_id, self.game_state)
64+
self.botmock.think.assert_not_called()
65+
self.sut.emit.assert_not_called()
66+
67+
def test_on_sync_shall_call_gameover_if_game_is_over(self):
68+
# activate gameover in game state
69+
self.game_state['ctx']['gameover'] = ['0']
70+
# call on_sync with bot game id
71+
self.sut.on_sync(self.botmock.game_id, self.game_state)
72+
self.botmock.gameover.assert_called_once_with(self.game_state['G'], self.game_state['ctx'])
73+
self.sut.emit.assert_not_called()
74+
75+
76+
class TestBot(unittest.TestCase):
77+
78+
def setUp(self):
79+
self.game_state = {'_stateID': 1234, 'G': {}, 'ctx': {
80+
'actionPlayers': ['1'],
81+
'phase': 'phase0'
82+
}}
83+
self.resulting_move = {'payload': 'action0'}
84+
# mock socket
85+
io.SocketIO = mock.Mock(spec=io.SocketIO)
86+
# instantiate SUT
87+
self.sut = Bot()
88+
self.sut.think = mock.MagicMock(name='think')
89+
self.sut.gameover = mock.MagicMock(name='gameover')
90+
91+
def test_make_move_shall_return_move_action(self):
92+
self.assertEqual(self.sut.make_move('type', 'foo', 'bar'),
93+
{'type': 'MAKE_MOVE',
94+
'payload': {
95+
'type': 'type',
96+
'args': ['foo', 'bar'],
97+
'playerID': self.sut.player_id
98+
}})
99+
100+
def test_game_event_shall_return_event_action(self):
101+
self.assertEqual(self.sut.game_event('foobar'),
102+
{'type': 'GAME_EVENT',
103+
'payload': {
104+
'type': 'foobar',
105+
'args': [],
106+
'playerID': self.sut.player_id}
107+
})
108+
109+
110+
if __name__ == '__main__':
111+
unittest.main()

0 commit comments

Comments
 (0)