From 002705a830f3e9ee2a8371f71db16713c3d9a09d Mon Sep 17 00:00:00 2001 From: Neel Somani Date: Sun, 26 Apr 2020 03:24:53 -0700 Subject: [PATCH] Add simple bots (#11) * Add bot moves logic * Fix claim selection * Fix tests * Add fill with bots to API * Add fill with bots button * Add usernames to API * Fix tests for names * Finish implementing usernames * Add reconnecting after user disconnects --- app.py | 32 ++--- backend.py | 210 +++++++++++++++++++++++----- constants.py | 3 +- literature/constants.py | 4 +- src/Game.test.js | 2 +- src/SetSelector.test.js | 15 +- src/components/ClaimDisplay.js | 2 +- src/components/ClaimModal.js | 1 + src/components/CorrectClaimModal.js | 1 + src/components/MoveDisplay.js | 5 +- src/components/Player.js | 4 +- src/components/Players.js | 1 + src/components/SetSelector.js | 7 +- src/components/Timer.js | 2 +- src/views/CreateRoom.js | 45 ++++-- src/views/Game.css | 2 +- src/views/Game.js | 57 +++++++- test_backend.py | 140 ++++++++++++++----- util.py | 20 +++ 19 files changed, 420 insertions(+), 133 deletions(-) create mode 100644 util.py diff --git a/app.py b/app.py index d7e9ccf..b80ca3d 100644 --- a/app.py +++ b/app.py @@ -1,32 +1,21 @@ import json import os -from threading import Event, Thread from flask import Flask, request from flask_sockets import Sockets import gevent from backend import RoomManager +import util app = Flask(__name__, static_folder='build/', static_url_path='/') app.debug = 'DEBUG' in os.environ sockets = Sockets(app) -room_manager = RoomManager() - - -def call_repeatedly(interval, func, *args): - stopped = Event() - - def loop(): - while not stopped.wait(interval): - func(*args) - Thread(target=loop).start() - return stopped.set - - -call_repeatedly(RoomManager.DELETE_ROOMS_AFTER_MIN * 60, - room_manager.delete_unused_rooms) +room_manager = RoomManager(app.logger) +util.schedule(RoomManager.DELETE_ROOMS_AFTER_MIN * 60, + room_manager.delete_unused_rooms, + repeat=True) @app.route('/') @@ -63,21 +52,22 @@ def submit(ws): continue app.logger.info('Handling message: {}'.format(msg)) - room_manager.handle_message(user_msg, app.logger) + room_manager.handle_message(user_msg) @sockets.route('/receive') def receive(ws): """ Register the WebSocket to send messages to the client. """ - game_uuid, player_uuid, n_players = ( + game_uuid, player_uuid, n_players, username = ( request.args.get('game_uuid'), request.args.get('player_uuid'), - request.args.get('n_players') + request.args.get('n_players'), + request.args.get('username') ) room_manager.join_game(client=ws, - logger=app.logger, player_uuid=player_uuid, game_uuid=game_uuid, - n_players=n_players) + n_players=n_players, + username=username) while not ws.closed: gevent.sleep(0.1) diff --git a/backend.py b/backend.py index e8c7a69..4272244 100644 --- a/backend.py +++ b/backend.py @@ -1,4 +1,5 @@ import json +import random import time import uuid @@ -6,6 +7,7 @@ import literature from constants import * +import util VISITOR_PLAYER_ID = -1 @@ -15,15 +17,14 @@ class RoomManager: DEFAULT_N_PLAYERS = 4 DELETE_ROOMS_AFTER_MIN = 10 - def __init__(self): + def __init__(self, logger): self.games = {} + self.logger = logger - def handle_message(self, message, logger): + def handle_message(self, message): game = self.games.get(message['game_uuid']) if game is None: - logger.exception( - 'Invalid game_uuid: {}'.format(json.dumps(message)) - ) + self.logger.info('Invalid game_uuid: {}'.format(message)) return game.handle_message(message) @@ -39,10 +40,10 @@ def _parse_n_players(cls, n_players, logger): def join_game(self, client, - logger, player_uuid=None, game_uuid=None, - n_players=DEFAULT_N_PLAYERS): + n_players=DEFAULT_N_PLAYERS, + username=None): """ Register a WebSocket connection for updates. @@ -50,31 +51,34 @@ def join_game(self, - game_uuid - player_uuid - n_players + - username Order of priorities: 1. Reconnect as player `player_uuid` to game `game_uuid` if possible. 2. Attempt connecting to `game_uuid` as any player. 3. Create new game with `n_players`. """ - if game_uuid in self.games: if player_uuid in self.games[game_uuid].users: # Connect as this player to the game. + self.logger.info( + 'Player {} has reconnected'.format(player_uuid)) player = self.games[game_uuid].users[player_uuid] player.connected = True player.socket = client + player.username = username self.games[game_uuid].register_with_uuid(player_uuid) else: - self.games[game_uuid].register_new_player(client) + self.games[game_uuid].register_new_player(client, username) return - n_players = RoomManager._parse_n_players(n_players, logger) + n_players = RoomManager._parse_n_players(n_players, self.logger) game_uuid = _uuid(self.games) self.games[game_uuid] = LiteratureAPI(game_uuid=game_uuid, - logger=logger, + logger=self.logger, n_players=n_players, time_limit=60) - self.games[game_uuid].register_new_player(client) + self.games[game_uuid].register_new_player(client, username) def delete_unused_rooms(self): """ Delete rooms that have not executed a move in the last @@ -86,10 +90,26 @@ def delete_unused_rooms(self): class User: - def __init__(self, socket, player_n, connected): + MAX_USERNAME_LENGTH = 20 + + def __init__(self, socket, player_n, connected, username): self.socket = socket self.player_n = player_n self.connected = connected + self.username = username + + @property + def username(self): + if not self.connected: + return 'Bot {}'.format(self.player_n) + return self._username + + @username.setter + def username(self, u): + if not u or u.strip() == '': + self._username = 'Player {}'.format(self.player_n) + else: + self._username = u[:User.MAX_USERNAME_LENGTH] class LiteratureAPI: @@ -97,6 +117,7 @@ class LiteratureAPI: Interface for registering and updating WebSocket clients for a given game. """ + BOT_SECOND_DELAY = 10 def __init__(self, game_uuid, @@ -123,28 +144,29 @@ def __init__(self, self.current_players = 0 # `last_executed_move` is the timestamp of the last executed move, # used to determine if this game is inactive. - self.last_executed_move = 0 + current_time = time.time() + self.last_executed_move = current_time # `move_timestamp` gives the base time for the current time limit, # which might be later than the `last_executed_move` if the turn is # forcibly switched. - self.move_timestamp = 0 + self.move_timestamp = current_time self.time_limit = time_limit self.logger.info('Initialized game {}'.format(game_uuid)) + self.stop_bots = lambda: None - def register_new_player(self, client): + def register_new_player(self, client, username): """ Register a new user for this game. """ - connected = False if self.current_players >= self.n_players: player_n = VISITOR_PLAYER_ID else: - connected = True player_n = self.current_players self.current_players += 1 player_uuid = _uuid(self.users) self.users[player_uuid] = User(socket=client, player_n=player_n, - connected=connected) + connected=True, + username=username) self.register_with_uuid(player_uuid) def register_with_uuid(self, player_uuid): @@ -164,6 +186,7 @@ def register_with_uuid(self, player_uuid): 'payload': payload }) self.logger.info('Sent registration to user {}'.format(player_uuid)) + self._send_player_names() if self.current_players == self.n_players: self.logger.info('Received {} players for game {}'.format( @@ -189,38 +212,143 @@ def _send(self, client, data): except: for player_uuid in self.users: if self.users[player_uuid].socket == client: - self.users[player_uuid].connected = False + u = self.users[player_uuid] + u.connected = False self.logger.info('Player {} is disconnected from game {}' .format(player_uuid, self.uuid)) - # TODO(@neel): Replace disconnected player with bot. + self._send_player_names() - def _send_all(self, message): + def _send_all(self, message, exclude_bots=False): """ Send a message to all clients. """ for user in self.users.values(): + if exclude_bots and not user.connected: + continue gevent.spawn(self._send, user.socket, message) + def _send_player_names(self): + """ Send the players names to all players. """ + names = {u.player_n: u.username for u in self.users.values()} + self._send_all({ + 'action': PLAYER_NAMES, + 'payload': { + 'names': names + } + }, exclude_bots=True) + + def _fill_bots(self, message): + """ Fill the remaining players with bots. """ + if message.get('key') not in self.users: + return + for i in range(self.n_players - self.current_players): + self.register_new_player( + util.BotClient(), + 'Bot {}'.format(self.current_players + i)) + def handle_message(self, message): action_map = { CLAIM: self._claim, MOVE: self._move, - SWITCH_TEAM: self._switch_team + SWITCH_TEAM: self._switch_team, + START_GAME: self._fill_bots } fn = action_map.get(message['action']) if fn is None: self.logger.exception( 'Received bad action for message: {}' - .format(json.dumps(message)) + .format(message) ) return fn(message.get('payload', {})) + def _move_if_possible(self, user, use_all_knowledge): + """ + Return a list of valid Moves for this User. + + If `use_all_knowledge` is True, then only return moves that could + possibly be successful. + """ + player = self.game.players[user.player_n] + moves = [] + for p in self.game.players: + moves.extend([ + player.asks(p).to_give( + literature.Card.Name(r, s) + ) + for r in literature.MINOR | literature.MAJOR + for s in literature.Suit + if player.valid_ask(p, + literature.Card.Name(r, s), + use_all_knowledge) + ]) + if len(moves) != 0: + return moves[int(random.random() * len(moves))] + + def execute_bot_moves(self): + """ + Make claims on behalf of bots. Make a move if the current turn + is a bot's. + + Stop once the first claim or move is made. The function should be + called with a delay so the users have time to read the moves. + """ + for player_uuid, p in self.users.items(): + if p.connected: + continue + claims = self.game.players[p.player_n].evaluate_claims() + new_claims = { + h: c for h, c in claims.items() + if self.game.claims[h] == literature.Team.NEITHER + } + if len(new_claims) == 0: + continue + self.logger.info('Making claim on behalf of bot {}' + .format(player_uuid)) + _random_claim = list(new_claims.keys())[0] + self.handle_message({ + 'action': CLAIM, + 'payload': { + 'key': player_uuid, + 'possessions': { + c.serialize(): p.unique_id + for c, p in new_claims[_random_claim].items() + } + } + }) + return + self.logger.info('Bots for game {} found no claims this turn' + .format(self.uuid)) + + current_uuid = None + for player_uuid in self.users: + if self.users[player_uuid].player_n == self.game.turn.unique_id: + current_uuid = player_uuid + break + if not current_uuid: + return + + bot = self.users[current_uuid] + if not bot.connected: + self.logger.info('Executing move for bot {}'.format(current_uuid)) + move = self._move_if_possible(user=bot, use_all_knowledge=True) + if not move: + move = self._move_if_possible(user=bot, + use_all_knowledge=False) + self.handle_message({ + 'action': MOVE, + 'payload': { + 'key': current_uuid, + 'respondent': move.respondent.unique_id, + 'card': move.card.serialize() + } + }) + def _claim(self, payload): """ Evaluate whether a player's claim is valid. Send the whether the player was correct in addition to the correct pairings to all players. - The included fields are: + The included fields of the response are: - `claim_by` - `half_suit` - `turn` @@ -250,12 +378,6 @@ def _claim(self, payload): _random_card = list(payload['possessions'])[0] half_suit = literature.deserialize(_random_card[:-1], _random_card[-1]).half_suit() - score = { - t.name.lower(): - sum(self.game.claims[literature.HalfSuit(h, s)] == t - for h in literature.Half for s in literature.Suit) - for t in literature.Team - } self._send_all({ 'action': CLAIM, 'payload': self._with_player_info({ @@ -269,11 +391,13 @@ def _claim(self, payload): 'truth': { c.serialize(): p.unique_id for c, p in self.game.actual_possessions[half_suit].items() - }, - 'score': score + } }) }) self._send_hands() + self.stop_bots() + self.stop_bots = util.schedule(LiteratureAPI.BOT_SECOND_DELAY, + self.execute_bot_moves) def _move(self, payload): """ @@ -325,13 +449,20 @@ def _send_updated_game_state(self): def _with_player_info(self, payload): """ - Add the `move_timestamp` and `n_cards` to the dictionary. + Add the `move_timestamp`, `score`, and `n_cards` to the dictionary. """ + score = { + t.name.lower(): + sum(self.game.claims[literature.HalfSuit(h, s)] == t + for h in literature.Half for s in literature.Suit) + for t in literature.Team + } payload.update({ 'move_timestamp': self.move_timestamp, 'n_cards': { i.unique_id: i.unclaimed_cards() for i in self.game.players - } + }, + 'score': score }) return payload @@ -340,12 +471,13 @@ def _send_last_move(self): Send the last move, the current player's turn, and each player's number of cards to all players. - The included fields are: + The included fields of the response are: - `interrogator` - `respondent` - `card` - `success` - `turn` + - `score` - `move_timestamp` - `n_cards` @@ -360,6 +492,9 @@ def _send_last_move(self): 'turn': self.game.turn.unique_id }) }) + self.stop_bots() + self.stop_bots = util.schedule(LiteratureAPI.BOT_SECOND_DELAY, + self.execute_bot_moves) return last_move, move_success = ( @@ -376,6 +511,9 @@ def _send_last_move(self): 'success': move_success }) }) + self.stop_bots() + self.stop_bots = util.schedule(LiteratureAPI.BOT_SECOND_DELAY, + self.execute_bot_moves) def _send_hands(self): """ diff --git a/constants.py b/constants.py index ed56dd2..5094f99 100644 --- a/constants.py +++ b/constants.py @@ -7,5 +7,6 @@ LAST_MOVE = 'last_move' MOVE = 'move' REGISTER = 'register' -SCORE = 'score' SWITCH_TEAM = 'switch_team' +START_GAME = 'start_game' +PLAYER_NAMES = 'player_names' diff --git a/literature/constants.py b/literature/constants.py index 8490219..1438a95 100644 --- a/literature/constants.py +++ b/literature/constants.py @@ -28,8 +28,8 @@ def __lt__(self, other): } # Convenience constants -MINOR = SETS[Half.MINOR] -MAJOR = SETS[Half.MAJOR] +MINOR: Set[int] = SETS[Half.MINOR] +MAJOR: Set[int] = SETS[Half.MAJOR] RANK_NAMES = {i: str(i) for i in range(2, 11)} RANK_NAMES[1] = "A" diff --git a/src/Game.test.js b/src/Game.test.js index 62b752b..0233eca 100644 --- a/src/Game.test.js +++ b/src/Game.test.js @@ -35,7 +35,7 @@ function initSixPlayerGame() { action: 'register', payload: { success: true, - uuid: PLAYER_KEY, + player_uuid: PLAYER_KEY, player_n: 0, n_players: 6, time_limit: 30 diff --git a/src/SetSelector.test.js b/src/SetSelector.test.js index 2e4f289..a87adf8 100644 --- a/src/SetSelector.test.js +++ b/src/SetSelector.test.js @@ -18,27 +18,32 @@ it('renders without crashing', () => { }) it('selects the correct cards by default', () => { - const correct = { + const preselected = { '8D': 0, '9D': 1, - '10D': 0, 'JD': 1, 'QD': 3, 'KD': 3 } const makeClaim = (possessions) => { - Object.keys(possessions).forEach((k) => { - expect(possessions[k]).toBe(correct[k]); + Object.keys(preselected).forEach((k) => { + expect(possessions[k]).toBe(preselected[k]); }) + expect(possessions['10D']).toBe(0); } ReactDOM.render( , container); + const radios = container.getElementsByClassName('claim-10D'); + for (let i = 0; i < radios.length; i++) { + if (radios[i].value == 0) radios[i].click(); + } + container.getElementsByClassName('MakeClaimButton')[0].click(); }) diff --git a/src/components/ClaimDisplay.js b/src/components/ClaimDisplay.js index be2f525..5b55041 100644 --- a/src/components/ClaimDisplay.js +++ b/src/components/ClaimDisplay.js @@ -32,7 +32,7 @@ export default class ClaimDisplay extends Component { this.props.showFullClaim()}>{success}: - {' '}Player {this.props.claimBy} claims + {' '}{this.props.playerNames[this.props.claimBy.toString()]} claims {' ' + this.props.halfSuit.half + ' ' + this.props.halfSuit.suit} )} diff --git a/src/components/ClaimModal.js b/src/components/ClaimModal.js index f35551a..456f5de 100644 --- a/src/components/ClaimModal.js +++ b/src/components/ClaimModal.js @@ -40,6 +40,7 @@ export default class ClaimModal extends Component { {!this.state.showSets && } diff --git a/src/components/CorrectClaimModal.js b/src/components/CorrectClaimModal.js index f4870a6..218458e 100644 --- a/src/components/CorrectClaimModal.js +++ b/src/components/CorrectClaimModal.js @@ -15,6 +15,7 @@ export default class ClaimModal extends Component {
diff --git a/src/components/MoveDisplay.js b/src/components/MoveDisplay.js index 9799956..bde8e08 100644 --- a/src/components/MoveDisplay.js +++ b/src/components/MoveDisplay.js @@ -9,8 +9,9 @@ export default class MoveDisplay extends Component { } const success = (this.props.success && 'Success') || 'Failure'; return
- {success}: Player {this.props.interrogator} - {' ' + this.props.card} from Player {this.props.respondent} + {success}: {this.props.playerNames[this.props.interrogator.toString()]} + {' ' + this.props.card} from {this.props.playerNames[ + this.props.respondent.toString()]}
} } diff --git a/src/components/Player.js b/src/components/Player.js index 0a8b9ea..71f5d44 100644 --- a/src/components/Player.js +++ b/src/components/Player.js @@ -23,9 +23,9 @@ export default class Players extends Component { src={icons[this.props.playerN % 2]} style={{ width: '100%' }} />

- Player {this.props.playerN} + {this.props.playerName}

- {this.props.nCards && + {(this.props.nCards !== undefined) &&

{this.props.nCards} cards

} } diff --git a/src/components/Players.js b/src/components/Players.js index 86d7b95..f620bca 100644 --- a/src/components/Players.js +++ b/src/components/Players.js @@ -12,6 +12,7 @@ export default class Players extends Component { showModal={this.props.showModal} turn={this.props.turn} playerN={p} + playerName={(this.props.playerNames || {})[p.toString()]} nCards={(this.props.nCards || {})[p]} />)} ; } diff --git a/src/components/SetSelector.js b/src/components/SetSelector.js index 75afe51..9a905a7 100644 --- a/src/components/SetSelector.js +++ b/src/components/SetSelector.js @@ -51,9 +51,9 @@ export default class SetSelector extends Component { const cardDict = this.state.possessions[p]; possessions[p] = {}; Object.keys(cardDict).forEach((c) => { - if (c === card && p !== player) { + if (c === card && parseInt(p) !== player) { possessions[p][c] = false; - } else if (c === card && p === player) { + } else if (c === card && parseInt(p) === player) { possessions[p][c] = true; } else { possessions[p][c] = cardDict[c]; @@ -70,6 +70,7 @@ export default class SetSelector extends Component { type='radio' value={player} name={'claim-' + card} + className={'claim-' + card} {...{ disabled: this.disabled[card] }} {...{ checked: this.state.possessions[player][card] }} onChange={() => this.handleOptionChange(card, player)} /> @@ -88,7 +89,7 @@ export default class SetSelector extends Component { .map((p) => + {(this.props.playerNames || {})[p.toString()]} )} ))} diff --git a/src/components/Timer.js b/src/components/Timer.js index 4309b09..07cf3f9 100644 --- a/src/components/Timer.js +++ b/src/components/Timer.js @@ -42,7 +42,7 @@ export default class Timer extends Component { src={img} height={15} width={15} - alt='Team Icon' /> Player {this.props.playerN} + alt='Team Icon' /> {this.props.playerNames[this.props.playerN]} ) return
diff --git a/src/views/CreateRoom.js b/src/views/CreateRoom.js index e235a32..5b168b8 100644 --- a/src/views/CreateRoom.js +++ b/src/views/CreateRoom.js @@ -5,13 +5,14 @@ import './CreateRoom.css'; class CreateRoom extends Component { constructor(props) { super(props); - this.state = { - room: '' - } } - handleJoin() { - window.location = '/game/' + this.state.room; + handleJoin(event) { + event.preventDefault(); + window.location = '/game/' + this.roomCode.value + '?' + + window.jQuery.param( + { 'username': this.username.value } + ); } render() { @@ -19,21 +20,37 @@ class CreateRoom extends Component {

Literature

-
- New Game:{' '} -
+ +
+ Username: this.username = input} /> +
+
+ New Game:{' '} - -
- Join Room: this.setState({ room: e.target.value })} - type='text' /> - +
+ + Join Room:{' '} +
+ this.roomCode = input} + type='text' /> + +
); diff --git a/src/views/Game.css b/src/views/Game.css index b6559e5..0c4d52e 100644 --- a/src/views/Game.css +++ b/src/views/Game.css @@ -82,7 +82,7 @@ border: 1px solid black; } -.ClaimButton { +.ClaimButton, .BotsButton { position: absolute; bottom: 2%; right: 2%; diff --git a/src/views/Game.js b/src/views/Game.js index 41b4790..c476c11 100644 --- a/src/views/Game.js +++ b/src/views/Game.js @@ -22,6 +22,8 @@ class Game extends Component { super(props); const claims = {}; SET_INDICATORS.forEach((s) => claims[s] = UNCLAIMED); + const playerNames = {}; + [...Array(8).keys()].forEach((p) => playerNames[p] = 'Player ' + p); this.state = { uuid: '', gameUuid: '', @@ -36,16 +38,28 @@ class Game extends Component { even: 0, odd: 0, discard: 0 - } + }, + playerNames }; const audioUrl = process.env.PUBLIC_URL + '/bell.mp3'; this.bell = new Audio(audioUrl); } + startGame() { + this.sendMessage({ + action: 'start_game', + game_uuid: this.state.gameUuid, + payload: { + key: this.state.uuid + } + }) + } + makeClaim(possessions) { this.hideClaimModal(); this.sendMessage({ action: 'claim', + game_uuid: this.state.gameUuid, payload: { key: this.state.uuid, possessions @@ -85,6 +99,7 @@ class Game extends Component { makeMove(card, toBeRespondent) { this.sendMessage({ action: 'move', + game_uuid: this.state.gameUuid, payload: { key: this.state.uuid, respondent: toBeRespondent, @@ -104,7 +119,7 @@ class Game extends Component { time_limit, game_uuid } = payload; - // TODO(@neel): Store uuid when received. + localStorage.setItem(PLAYER_UUID, player_uuid); this.setState({ uuid: player_uuid, playerN: player_n, @@ -128,7 +143,8 @@ class Game extends Component { success, card, respondent, - interrogator + interrogator, + score } = payload; if (turn === this.state.playerN && turn !== this.state.turn) this.bell.play(); @@ -139,7 +155,11 @@ class Game extends Component { success, card, respondent, - interrogator + interrogator, + score: { + ...this.state.score, + ...score + } }) if (turn !== this.state.playerN) this.hideMakeMoveModal(); } @@ -163,7 +183,10 @@ class Game extends Component { nCards: n_cards, moveTimestamp: move_timestamp, turn, - score, + score: { + ...this.state.score, + ...score + }, claims, lastClaim: { claimBy: claim_by, @@ -175,6 +198,10 @@ class Game extends Component { if (turn !== this.state.playerN) this.hideMakeMoveModal(); } + playerNames(payload) { + this.setState({ playerNames: payload.names }); + } + handleMessage(message) { let data = JSON.parse(message.data); console.log('Received: ' + JSON.stringify(data)); @@ -193,6 +220,9 @@ class Game extends Component { case 'claim': this.claim(data.payload) break; + case 'player_names': + this.playerNames(data.payload) + break; default: throw new Error('Unhandled action: ' + data.action); } @@ -215,6 +245,7 @@ class Game extends Component { const player_uuid = localStorage.getItem(PLAYER_UUID); const sendParams = window.jQuery.param({ n_players: queryParams.get('n_players'), + username: queryParams.get('username'), game_uuid: pathParams[pathParams.length - 1], player_uuid }); @@ -237,26 +268,33 @@ class Game extends Component { this.setState({ showFullClaim: true })} /> this.sendMessage({ 'action': 'switch_team' })} + switchTeam={() => this.sendMessage({ + action: 'switch_team', + game_uuid: this.state.gameUuid + })} turn={this.state.turn} gameUuid={this.state.gameUuid} + playerNames={this.state.playerNames} playerN={this.state.playerN} /> { this.setState({ showFullClaim: false }) }} />} - {this.state.playerN !== -1 && } + {!this.state.moveTimestamp && }
); } diff --git a/test_backend.py b/test_backend.py index 6fe9e04..08a2f1b 100644 --- a/test_backend.py +++ b/test_backend.py @@ -14,10 +14,17 @@ Suit ) -from backend import LiteratureAPI, RoomManager, VISITOR_PLAYER_ID +from backend import ( + LiteratureAPI, + RoomManager, + User, + VISITOR_PLAYER_ID +) from constants import * +import util MOCK_UNIQUE_ID = '1' +MOCK_NAME = 'John' MISSING_CARD = Card.Name(3, Suit.CLUBS) TIME_LIMIT = 30 N_PLAYERS = 4 @@ -54,16 +61,21 @@ def mock_get_game(n_players): turn_picker=lambda: 0) -@pytest.fixture(autouse=True) +def mock_schedule(interval, func, repeat=False): + return lambda: None + + +@pytest.fixture() def setup_mocking(monkeypatch): # gevent does not execute for tests monkeypatch.setattr(gevent, 'spawn', sync_exec) monkeypatch.setattr(time, 'time', lambda: 0) monkeypatch.setattr(literature, 'get_game', mock_get_game) + monkeypatch.setattr(util, 'schedule', mock_schedule) @pytest.fixture() -def api(monkeypatch): +def api(monkeypatch, setup_mocking): # Pick the first player to start return LiteratureAPI( game_uuid=MOCK_UNIQUE_ID, @@ -78,7 +90,7 @@ def initialized_room(api): in_room = [] for _ in range(N_PLAYERS): in_room.append(MockClient()) - api.register_new_player(in_room[-1]) + api.register_new_player(in_room[-1], None) return { 'clients': in_room, 'api': api @@ -87,13 +99,16 @@ def initialized_room(api): def test_registration(api): c = MockClient() - api.register_new_player(c) - assert len(c.messages) == 1 - msg = c.messages[0] - assert msg['payload']['player_n'] != VISITOR_PLAYER_ID - assert 'player_uuid' in msg['payload'] - assert msg['payload']['time_limit'] == TIME_LIMIT - assert msg['payload']['n_players'] == N_PLAYERS + api.register_new_player(c, MOCK_NAME) + # Registration + player_names message + assert len(c.messages) == 2 + msg = _action_from_messages(c.messages, REGISTER) + assert msg['player_n'] != VISITOR_PLAYER_ID + assert 'player_uuid' in msg + assert msg['time_limit'] == TIME_LIMIT + assert msg['n_players'] == N_PLAYERS + msg = _action_from_messages(c.messages, PLAYER_NAMES) + assert msg['names']['0'] == MOCK_NAME def _action_from_messages(messages, action): @@ -106,26 +121,27 @@ def _action_from_messages(messages, action): def test_full_room(initialized_room): api, clients = initialized_room['api'], initialized_room['clients'] for i in clients: - assert len(i.messages) == 3 - assert i.messages[0]['action'] == REGISTER - recv_actions = { - i.messages[1]['action'], - i.messages[2]['action'] - } - assert HAND in recv_actions and LAST_MOVE in recv_actions + assert len(_filter_name_updates(i.messages)) == 3 + recv_actions = {m['action'] for m in i.messages} + assert REGISTER in recv_actions + assert HAND in recv_actions + assert LAST_MOVE in recv_actions + assert PLAYER_NAMES in recv_actions c = MockClient() - api.register_new_player(c) + api.register_new_player(c, None) msg = _action_from_messages(c.messages, REGISTER) assert msg['player_n'] == VISITOR_PLAYER_ID # The visitor should still receive the last move - assert len(c.messages) == 2 + for action in [REGISTER, LAST_MOVE, PLAYER_NAMES]: + _action_from_messages(c.messages, action) def test_switching_turn(monkeypatch, initialized_room): api, clients = initialized_room['api'], initialized_room['clients'] api.handle_message({'action': SWITCH_TEAM}) for i in clients: - assert len(i.messages) == 3 + # Message for registration, last_move, and hand + assert len(_filter_name_updates(i.messages)) == 3 # Get the current turn turn = _action_from_messages(clients[0].messages, LAST_MOVE)['turn'] @@ -134,7 +150,7 @@ def test_switching_turn(monkeypatch, initialized_room): monkeypatch.setattr(time, 'time', lambda: 45) api.handle_message({'action': SWITCH_TEAM}) for i in clients: - assert len(i.messages) == 5 + assert len(_filter_name_updates(i.messages)) == 5 current_turn = _action_from_messages( clients[0].messages[-2:], LAST_MOVE @@ -144,11 +160,11 @@ def test_switching_turn(monkeypatch, initialized_room): def test_switch_turn_before_start(monkeypatch, api): c = MockClient() - api.register_new_player(c) - assert len(c.messages) == 1 + api.register_new_player(c, None) + assert len(_filter_name_updates(c.messages)) == 1 monkeypatch.setattr(time, 'time', lambda: 45) api.handle_message({'action': SWITCH_TEAM}) - assert len(c.messages) == 1 + assert len(_filter_name_updates(c.messages)) == 1 def _get_p0_key(clients): @@ -159,9 +175,13 @@ def _get_p0_key(clients): raise ValueError('Player 0 not found') +def _filter_name_updates(messages): + return [m for m in messages if m['action'] != PLAYER_NAMES] + + def test_make_move(initialized_room): api, clients = initialized_room['api'], initialized_room['clients'] - assert len(clients[0].messages) == 3 + assert len(_filter_name_updates(clients[0].messages)) == 3 p0_key = _get_p0_key(clients) api.handle_message({ 'action': MOVE, @@ -171,7 +191,7 @@ def test_make_move(initialized_room): 'card': MISSING_CARD.serialize() } }) - assert len(clients[0].messages) == 5 + assert len(_filter_name_updates(clients[0].messages)) == 5 move = _action_from_messages(clients[0].messages[-2:], LAST_MOVE) assert move['success'] assert move['interrogator'] == 0 @@ -195,7 +215,7 @@ def test_claim(initialized_room): } } }) - assert len(clients[0].messages) == 5 + assert len(_filter_name_updates(clients[0].messages)) == 5 payload = _action_from_messages(clients[0].messages[-2:], CLAIM) assert payload['claim_by'] == 0 assert payload['half_suit']['half'] == 'minor' @@ -238,11 +258,10 @@ def test_game_complete(monkeypatch, initialized_room): assert msg['action'] == COMPLETE -def test_rooms(monkeypatch): - rm = RoomManager() +def test_rooms(setup_mocking): + rm = RoomManager(logging.getLogger(__name__)) new_room_client = MockClient() rm.join_game(new_room_client, - logging.getLogger(__name__), player_uuid=None, game_uuid=None, n_players=N_PLAYERS) @@ -252,7 +271,6 @@ def test_rooms(monkeypatch): game_uuid = msg['game_uuid'] player_uuid = msg['player_uuid'] rm.join_game(same_room_client, - logging.getLogger(__name__), player_uuid=None, game_uuid=game_uuid, n_players=None) @@ -262,7 +280,6 @@ def test_rooms(monkeypatch): msg['player_uuid'] != player_uuid player_reconnected = MockClient() rm.join_game(player_reconnected, - logging.getLogger(__name__), player_uuid=player_uuid, game_uuid=game_uuid, n_players=None) @@ -272,11 +289,10 @@ def test_rooms(monkeypatch): msg['player_uuid'] == player_uuid -def test_room_deletion(monkeypatch): - rm = RoomManager() +def test_room_deletion(monkeypatch, setup_mocking): + rm = RoomManager(logging.getLogger(__name__)) new_room_client = MockClient() rm.join_game(new_room_client, - logging.getLogger(__name__), player_uuid=None, game_uuid=None, n_players=N_PLAYERS) @@ -288,3 +304,55 @@ def test_room_deletion(monkeypatch): monkeypatch.setattr(time, 'time', lambda: 15 * 60) rm.delete_unused_rooms() assert len(rm.games) == 0 + + +def test_bot_moves(initialized_room): + api, clients = initialized_room['api'], initialized_room['clients'] + assert len(api.game.actual_possessions) == 0 + # Turn player 0 into a bot by breaking the WebSocket + for c in clients: + if c.messages[0]['payload']['player_n'] == 0: + c.send = util.BotClient.send + p0_key = _get_p0_key(clients) + api.handle_message({ + 'action': MOVE, + 'payload': { + 'key': p0_key, + 'respondent': 1, + 'card': MISSING_CARD.serialize() + } + }) + api.execute_bot_moves() + assert len(api.game.actual_possessions) == 1 + api.execute_bot_moves() + assert len(api.game.actual_possessions) == 2 + + +def test_start_game(api): + c = MockClient() + api.register_new_player(c, None) + assert api.current_players == 1 + api.handle_message({ + 'action': START_GAME, + 'payload': {} + }) + assert api.current_players == 1 + api.handle_message({ + 'action': START_GAME, + 'payload': { + 'key': c.messages[0]['payload']['player_uuid'] + } + }) + assert api.current_players == 4 + + +def test_user_object(): + no_name = User(MockClient(), 1, True, '') + assert no_name.username == 'Player 1' + real_name = User(MockClient(), 2, True, MOCK_NAME) + assert real_name.username == MOCK_NAME + real_name.connected = False + assert real_name.username == 'Bot 2' + long_string = 'a' * 30 + real_name.username = long_string + assert len(real_name.username) <= 20 diff --git a/util.py b/util.py new file mode 100644 index 0000000..6b5e930 --- /dev/null +++ b/util.py @@ -0,0 +1,20 @@ +""" Scheduling function """ +from threading import Event, Thread + + +def schedule(interval, func, repeat=False): + stopped = Event() + + def loop(): + while not stopped.wait(interval): + func() + if not repeat: + stopped.set() + Thread(target=loop).start() + return stopped.set + + +class BotClient: + """ A class to represent a bot user. """ + def send(self, _): + raise AssertionError('closed')