def test_hs_percentile(self): e = Engine( 'CoinPoker', 1, { 1: {'name': 'joe', 'balance': 1000, 'status': 1}, 2: {'name': 'jane', 'balance': 1000, 'status': 1}, }, 50, 100, 0, ) avail = e.available_actions() # p1 sb e.do(['c']) avail = e.available_actions() # p2 bb e.do(['k']) avail = e.available_actions() # p2 e.do(['b', 50]) avail = e.available_actions() # p1 e.do(['c']) avail = e.available_actions() # p2 e.do(['b', 100]) avail = e.available_actions() # # p1 # e.do('c') # avail = e.available_actions() # # # p2 # e.do(['b', 150]) # avail = e.available_actions() percentile = 50 history = [] ev = 1 while ev > 0 and percentile <= 60: mc = MonteCarlo(e, 1) mc.PERCENTILE = percentile mc.run(100) actions = mc.current_actions actions.sort(key=itemgetter(1), reverse=True) best_action = actions[0] ev = best_action[1] history.append((percentile, int(ev))) percentile += 1 assert 0 <= percentile <= 100
def test_showdown_hs(self): e = Engine( 'CoinPoker', 1, { 1: { 'name': 'joe', 'balance': 1000, 'status': 1 }, 2: { 'name': 'joe', 'balance': 1000, 'status': 1 }, }, 50, 100, 0, ) e.available_actions() # p1 e.do(['r', 100]) e.available_actions() # p2 e.do(['c']) e.available_actions() # p2 e.do(['k']) e.available_actions() # p1 e.do(['b', 200]) e.available_actions() # p2 has: # preflop_1 = l # preflop_2 = c # flop_1 = k hs = ES.showdown_hs(e, e.s, percentile=50) assert hs is not None assert 0 < hs < 1 hs2 = ES.showdown_hs(e, e.s, percentile=10) assert hs2 < hs hs3 = ES.showdown_hs(e, e.s, percentile=90) assert hs3 > hs res = ES.showdown_hs(e, e.s, 200) hits = res.hits.hits assert len(hits) == 200 assert hits[0]['_score'] > 4 assert hits[-1]['_score'] > 0
def test_showdown_hs(self): e = Engine( 'CoinPoker', 1, { 1: {'name': 'joe', 'balance': 1000, 'status': 1}, 2: {'name': 'joe', 'balance': 1000, 'status': 1}, }, 50, 100, 0, ) e.available_actions() # p1 e.do(['r', 100]) e.available_actions() # p2 e.do(['c']) e.available_actions() # p2 e.do(['k']) e.available_actions() # p1 e.do(['b', 200]) e.available_actions() # p2 has: # preflop_1 = l # preflop_2 = c # flop_1 = k hs = ES.showdown_hs(e, e.s, percentile=50) assert hs is not None assert 0 < hs < 1 hs2 = ES.showdown_hs(e, e.s, percentile=10) assert hs2 < hs hs3 = ES.showdown_hs(e, e.s, percentile=90) assert hs3 > hs res = ES.showdown_hs(e, e.s, 200) hits = res.hits.hits assert len(hits) == 200 assert hits[0]['_score'] > 4 assert hits[-1]['_score'] > 0
class Scraper(View): """Runs a scraper for a site. Will use the site to do the scraping, and with the data calculate what action needs to be taken and pass that into the engine and MC""" PATH_DEBUG = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'debug') ACTIONS_MAP = { 'f': 'fold', 'k': 'check', 'c': 'call', 'b': 'bet', 'r': 'raise', 'a': 'allin', } def __init__(self, site_name, seats, debug=False, replay=False, observe=False): self.debug = debug logger.debug('Debug {}'.format(self.debug)) self.observe = observe logger.debug('Observing {}'.format(self.observe)) self.replay = replay logger.debug('Replay {}'.format(self.replay)) if replay: self.load_files() if site_name == 'ps': self.site = PokerStars(seats, debug) elif site_name == 'pp': self.site = PartyPoker(seats, debug) elif site_name == 'zg': self.site = Zynga(seats, debug) elif site_name == 'cp': self.site = CoinPoker(seats, debug) else: raise NotImplementedError( '{} is not implemented'.format(site_name)) self.img = None # starting balance zero for ante on init self.players = { s: { 'name': 'joe', 'balance': 1000000, 'status': 1, } for s in range(1, seats + 1) } # do not add engine & mc self.btn = None # not currently in a game self.waiting_for_new_game = True # button moved self.button_moved = False # board moved to help finish phase self.board_moved = False # if tlc/button cannot be found, drop existing game after 5 seconds self.drop_game_start = None # on each player's turn, update balances self.last_thinking_seat = None # orphan call from previous first require thinking self.last_thinking_phase = None def load_files(self): """Replays through images saved during debug run""" files = [] for entry in os.scandir(self.PATH_DEBUG): if not entry.is_file() or entry.name.startswith('.'): logger.debug('skipping file {}'.format(entry.name)) continue files.append(entry.path) logger.info(f'loading {len(files)} files') self.files = sorted(files) def take_screen(self): """Get screen image Takes screen shot or load file if replaying Stats: 9.58% """ logger.info('taking screen shot') while True: if not self.replay: # 3840 x 2400 mac retina # pokerstars # img = ImageGrab.grab((1920, 600, 3840, 2400)) # coinpoker img = ImageGrab.grab((1760, 700, 3840, 2300)) img_file = os.path.join( self.PATH_DEBUG, '{}.png'.format(datetime.datetime.utcnow())) img.save(img_file) logger.debug('file saved locally to {}'.format(img_file)) else: img_path = self.files.pop(0) logger.debug('loading file: {}'.format(img_path)) img = Image.open(img_path) if not self.observe: img_full = img.convert('L') try: self.img = self.site.parse_top_left_corner(img_full) if self.drop_game_start: self.drop_game_start = None logger.info('Continuing existing game') except SiteException as e: logger.info(e) if not self.waiting_for_new_game: if not self.drop_game_start: self.drop_game_start = time.time() logger.warning('Drop game beginning...') elif time.time() - self.drop_game_start > 5: self.waiting_for_new_game = True self.button_moved = False logger.error('Game state aborted!') if self.debug: logger.warning('TLC not found in image!') time.sleep(1) continue try: btn = self.site.parse_dealer(self.img) except NoDealerButtonError as e: logger.warning(e) time.sleep(0.6) break else: if not self.btn: logger.debug( 'button initialised at {} on joining table'.format( btn)) self.btn = btn elif btn != self.btn: self.button_moved = True self.btn_next = btn logger.debug(f'button moved to {btn}!') self.check_board() # DONE # if self.debug: # input('$ check hands if lucky:') # for s, d in self.engine.data.items(): # if 'in' in d['status']: # self.site.parse_pocket_region(self.img, s) # always break (only continue when tlc & button found) break def check_board(self): """Check board and handles exception raised if card not identified. Card animation could be covering the cards. Instead of retrying rather return existing board""" if not hasattr(self, 'engine'): logger.info('Not checking board when no engine present') return if len(self.engine.board) >= 5: logger.info(f'Board already identified as {self.engine.board}') return logger.info('checking board...') board = self.site.parse_board(self.img) # works like this on CP # if not self.button_moved and not self.waiting_for_new_game and len(board) < len(self.engine.board): # raise BoardError('Board cannot be removed without button moving') if len(board) > len(self.engine.board): logger.debug( f'The board changed with {set(board) - set(self.engine.board)}' ) self.engine.board = board self.board_moved = True logger.debug('board: {}'.format(self.engine.board)) def run(self): """Run application""" while True: self.take_screen() if self.debug: self.img.show() time.sleep(1) if self.observe: continue # we are ready for a new game if self.waiting_for_new_game: logger.debug('waiting for a new game...') if self.button_moved: logger.debug( 'button moved and waiting for new game: create new game' ) self.start_new_game() else: logger.info('just waiting for new game to start...') time.sleep(1) continue # else game in progress else: logger.debug('still playing same game') if self.button_moved: logger.debug( 'button moved! we need to finish up what engine is') self.finish_it() elif self.engine.phase == self.engine.PHASE_SHOWDOWN: self.check_showdown_winner() time.sleep(1) elif self.engine.phase == self.engine.PHASE_GG: pot, total = self.site.parse_pot_and_total(self.img) if not pot and not total: logger.info(f'Game completed and pot allocated') self.finish_it() else: logger.info( 'Game completed but waiting for button move') time.sleep(0.5) else: logger.debug('button not moved, just continue loop') self.wait_player_action() logger.info('loop finished') def run_mc(self, timeout): """Runs MC analysis till timeout. This catches error where the amounts have varied too much from the current board by making the closes action""" logger.info('Running MC analysis') # if self.debug: # timeout = 0.4 if 'in' not in self.engine.data[self.site.HERO]['status']: time.sleep(0.2) else: # profiler = Profiler() # time_start = time.time() try: # if self.debug: # profiler.start() self.mc.run(timeout) # if self.debug: # profiler.stop() except EngineError as e: # if self.debug: # profiler.stop() logger.error(e) self.mc.init_tree() self.mc.run(timeout) # duration = time.time() - time_start # if not self.mc.queue.empty() and duration > timeout * 2: # with open(f'profile_{timeout}_{duration}.html', 'w') as f: # f.write(profiler.output_html()) # logger.warning(f'MC {timeout}s run took way longer at {duration}s') self.print() def wait_player_action(self): """Think a little. Always think at least 1 second after every player actioned. First detect if the phase haven't moved on by checking board cards Secondly detect if current player isn't still thinking""" self.run_mc(0.3) # todo check if gg in expected # todo then scan for foe pockets and exit # check actions till phase has caught up # board will not trigger (as cards cleanedup) as we will have button_moved flag # this works when card dealt and players still need to act # this fails when normally finished last action, then when turn comes the whole phase is # considered board phase... # check if phase from number of cards on board is what current player expected phase is if self.board_moved: # this works only if 'board_moved' (looking to catch up if card added to board) mapped_board = self.engine.BOARD_MAP[len(self.engine.board)] while self.engine.phase != mapped_board: logger.debug( f'board at: {mapped_board} and engine at {self.engine.phase}' ) # if second last person folded, or others allin if self.engine.phase in [ self.engine.PHASE_SHOWDOWN, self.engine.PHASE_GG ]: logger.info( f'Engine caught up to {mapped_board}, but gg & sd handled separately' ) return self.check_player_action() self.run_mc(0.3) self.board_moved = False # do not update last_thinking_phase as previous phase text still shows, giving wrong action # an allin would end here if self.engine.phase == self.engine.PHASE_SHOWDOWN: logger.debug('Game in phase showdown, not checking players') time.sleep(0.8) return # as long as someone else is thinking, run analysis logger.info('Checking parsing player...') try: current_s = self.site.parse_thinking_player(self.img) logger.debug(f'Current thinking player is {current_s}') except ThinkingPlayerError as e: logger.debug( f'Current thinking player is unknown, estimating action for {self.engine.s}' ) # during any phase, if pot removed, then have to loop to find winner with contrib pot, total = self.site.parse_pot_and_total(self.img) if not pot: while self.engine.phase not in [self.engine.PHASE_SHOWDOWN, self.engine.PHASE_GG] \ and self.engine.phase == self.last_thinking_phase: try: self.check_player_action(expect_text=True) except PlayerActionError as exc: # happened when player did not move, but thinking already moved. wtf return self.run_mc(0.3) if self.engine.phase == self.engine.PHASE_SHOWDOWN: self.check_showdown_winner() elif self.engine.phase == self.engine.PHASE_GG: self.finish_it() # else we can check for action considering we are in same phase elif self.last_thinking_phase == self.engine.phase: try: self.check_player_action(expect_text=True) except PlayerActionError as exc: # happened when player did not move, but thinking already moved. wtf # 2: caught thinking elapsed, but folded cards not yet removed logger.info(str(exc)) return self.run_mc(0.3) else: self.last_thinking_phase = self.engine.phase # if player is still thinking, then so can we if current_s == self.engine.q[0][0]: logger.debug( f'player to act {current_s} is still thinking, so can we...' ) if current_s != self.last_thinking_seat: self.check_player_name(current_s) self.check_player_balance(current_s) self.last_thinking_seat = current_s # longer thinking time for hero thinking_time = 1 if current_s == self.site.HERO else 0.4 self.run_mc(thinking_time) # player (in engine) to act is not the one thinking on screen # whilst it is not the expected player to act use the same current img to catch up else: while current_s != self.engine.q[0][0]: logger.debug( f'taking action for {self.engine.q[0][0]} as he is not thinking on screen' ) if self.engine.phase in [ self.engine.PHASE_SHOWDOWN, self.engine.PHASE_GG ]: logger.warning( f'exiting board phase as engine is now in {self.engine.phase}' ) return try: self.check_player_action() except (BalancesError, PlayerActionError) as e: logger.warning(e) # retry with new screen return self.run_mc(0.3) def check_player_action(self, expect_text=False): """It is certain the expected player is not thinking, thus he finished his turn: check the player action. Based on expected moves we can infer what he did Balance would change for: call, bet, raise, allin and not for: fold, check Fold is most common and easy to detect pocket cards Check (if so) is easy if balance did not change Otherwise balance has changed: balance and contrib change should match, just send bet to engine, it'll correct the action """ s = self.engine.q[0][0] phase = self.engine.phase logger.info(f'check player {s} action') logger.info(f'expecting one of {self.expected} during {phase}') if 'fold' not in self.expected: logger.error(f'fold is not in {self.expected}') raise PlayerActionError( 'End of game should not be here: player cannot fold') pocket = self.check_players_pockets(s) contrib = self.site.parse_contribs(self.img, s) cmd = self.site.infer_player_action(self.img, s, phase, pocket, contrib or 0, self.engine, self.engine.current_pot, self.board_moved, self.expected, expect_text) # do action, rotate, and get next actions logger.debug(f'parsed action {cmd}') action = self.engine.do(cmd) action_name = self.ACTIONS_MAP[action[0]] logger.info(f'Player {s} did {action_name} {action}') # cut tree based on action # do not have to cut tree when button moved if not self.button_moved: # self.mc.analyze_tree() child_nodes = self.mc.tree.children(self.mc.tree.root) logger.debug( f'{len(child_nodes)} child nodes on tree {self.mc.tree.root}') # logger.info('nodes:\n{}'.format(json.dumps([n.tag for n in child_nodes], indent=4, default=str))) action_nodes = [ n for n in child_nodes if n.data['action'].startswith(action_name) ] # create new if not action_nodes: logger.warning( f'action {action_name} not found in nodes {[n.data["action"] for n in child_nodes]}' ) self.mc.init_tree() logger.debug('tree recreated') # subtree else: # direct if len(action_nodes) == 1: node = action_nodes[0] self.mc.tree = self.mc.tree.subtree(node.identifier) logger.debug(f'Tree branched from single node {node.tag}') logger.debug(f'tree branched from single node {node.data}') # proximity else: nodes_diffs = { abs(n.data['amount'] - action[1]): n for n in action_nodes } node = nodes_diffs[min(nodes_diffs.keys())] self.mc.tree = self.mc.tree.subtree(node.identifier) logger.debug( f'tree recreated from closest node {node.tag}') logger.debug( f'tree recreated from closest node {node.data}') # increment traversed level if not node.tag.endswith('_{}_{}'.format(s, phase)): logger.error( f'Finished player {s} in {phase} not in subtree tag {node.tag}' ) self.mc.init_tree() logger.debug('get next actions') self.expected = self.engine.available_actions() def check_players_pockets(self, filter_seat=None): """Check if player has back side of pocket, otherwise check what his cards are""" pockets = {} for i in range(self.site.seats): s = i + 1 if filter_seat and filter_seat != s: continue # could already have hand # but cannot check here # as it does not pick up when hero folded # if hasattr(self, 'engine') and self.engine.data[s]['hand'] not in [['__', '__'], [' ', ' ']]: # pockets[s] = self.engine.data[s] # continue # check for back side if self.site.parse_pocket_back(self.img, s): logger.info(f'Player {s} has hole cards') pockets[s] = self.site.HOLE_CARDS continue # check if showing cards pocket = self.site.parse_pocket_cards(self.img, s) if pocket: logger.info(f'Player {s} is showing {pocket}') pockets[s] = pocket if hasattr(self, 'engine'): self.engine.data[s]['hand'] = pocket continue logger.info(f'Player {s} has no cards') if filter_seat: return pockets.get(filter_seat) return pockets def check_player_name(self, seat): """Get name hashes and set it to players""" name = self.site.parse_names(self.img, seat) if name: self.players[seat]['name'] = name logger.info(f'Player {seat} name is {name}') def check_player_balance(self, seat): """Update player balances""" balance = self.site.parse_balances(self.img, seat) if balance: self.players[seat]['balance'] = balance logger.debug(f'Player {seat} balance is {balance}') def start_new_game(self): """Dealer button moved: create new game! Get players that are still IN: some sites have balances always available, but some are blocked by text. Thus at least 2 pockets are required. Get small blind and big blind and ante. Create new engine Create new MC Not waiting for a new game anymore. """ # for joining table first time only if not self.btn: logger.info(f'init button at {self.btn_next}') self.btn = self.btn_next return logger.info('button moved: creating new game...') self.btn = self.btn_next # there must be pockets for a game to have started pockets = self.check_players_pockets() if len(pockets) < 2: logger.debug( f'Game has not started as there are {len(pockets)} pockets right now' ) time.sleep(0.1) return # pot and total are required for a game to have started # cannot check pot anymore for when no ante, no pot in middle :( pot, total = self.site.parse_pot_and_total(self.img) if not total: logger.debug( f'Game has not started as there are no pot or total right now') time.sleep(0.1) return # contribs are required for blinds! contribs = self.site.parse_contribs(self.img) # seems there can be 1 blind, probably when blind fell out during prev round # if len(contribs) == 1: # logger.error('Found only 1 blind. Not supported.') # self.button_moved = False # return # requires 2 blinds to get sb and bb if not len(contribs): logger.debug( 'Game has not started as there are no contribs right now') time.sleep(0.1) return # not doing any more checks, since if they have cards and there is a pot: game on! # self.check_player_name() # checked at flop to save time during start # always have text on it, so who cares # balances = self.site.parse_balances(self.img) vs_players = set(list(pockets.keys()) + list(contribs.keys())) ante = self.check_ante(len(vs_players), pot) sb, bb = self.check_blinds(contribs) self.pre_start(vs_players, contribs, ante) logger.debug('creating engine...') self.engine = Engine(self.site.NAME, self.btn, self.players, sb, bb, ante) # why does 'hand' return? self.expected = self.engine.available_actions() logger.debug( f'seat {self.engine.q[0][0]} available actions: {self.expected}') logger.debug('creating MC...') self.mc = MonteCarlo(engine=self.engine, hero=self.site.HERO) self.post_start(pockets) logger.info('new game created!') self.waiting_for_new_game = False self.button_moved = False self.board_moved = False def check_ante(self, vs, pot): """Check ante from contrib total before gathering/matched. Using player balances instead of recently scraped balances.""" # no pot so there is no ante paid yet if not pot: return 0 ante = pot / vs logger.debug(f'Ante {ante} from current pot {pot} and vs {vs}') if not ante.is_integer(): logger.warning(f'Ante {ante} is not integer') ante = int(round(ante)) logger.info(f'Ante: {ante}') return ante def check_blinds(self, contribs): """SB and BB from ante structure. Since hero blocks the play, this should be always found.""" # skip SB in tournament fallout if len(contribs) == 3 and contribs.get(5) == 7: logger.info('Fixed player 5 wrong contrib of 7') del contribs[5] contribs_sorted = sorted([c for c in contribs.values() if c]) if len(contribs_sorted) == 1: return None, contribs_sorted[0] sb, bb, *others = contribs_sorted logger.info(f'Blinds found: SB {sb} and BB {bb}') if sb * 2 != bb: # revert to seating order logger.warning('Smallest SB is not 1:2 to BB') sb, bb, *_ = contribs.values() # small blind is all-in if sb < bb / 2: s = list(contribs.keys())[0] self.players[s]['balance'] = sb sb = bb / 2 # small blind is all-in if bb < sb * 2: s = list(contribs.keys())[1] self.players[s]['balance'] = bb bb = sb * 2 return sb, bb def pre_start(self, vs_players, contribs, ante): """Add contribs back to balances for when engine take action it will subtract it again from matched/contrib. The status of players can be set if they have any balance/contrib""" for s in range(1, self.site.seats + 1): status = s in vs_players self.players[s]['status'] = status self.players[s]['sitout'] = False if not status: continue # balance might not be scraped as it might be obscured by text # if s in balances: # balance = balances[s] # balance_diff = self.players[s]['balance'] - balance # if balance_diff: # logger.warning(f'player {s} balance {balance} out by {balance_diff}') # self.players[s]['balance'] = balance # if player is blind, then return money for engine to subtract on init. same for ante. # BUT with coinpoker, the balances are not scraped # so no need to add it back # contrib = contribs.get(s, 0) # if contrib: # self.players[s]['balance'] += contrib # logger.debug(f'player {s} contrib {contrib} added back to balance') # if ante: # self.players[s]['balance'] += ante # logger.debug(f'player {s} ante {ante} added back to balance') logger.info('pre start done') if self.debug: logger.info('Is all the contribs/ante correct?') def post_start(self, pockets): """Post start. Engine and MC has been created. Set hero hand immediately for analysis of EV""" hero = self.site.HERO pocket = pockets.get(hero) if pocket: self.engine.data[hero]['hand'] = pocket logger.info(f'Hero (p{hero}) hand set to {pocket}') else: logger.error('No hero pocket found!') self.print() def finish_it(self): """Finish the game. Should already have winner.""" logger.info('finish the game') # todo BUG: game does get here in showdown # todo BUG: cannot go to showdown, as the game has ended long time ago, has to finish it # if not self.engine.winner: # raise GamePhaseError(f'Game does not have winner but in phase {self.engine.phase}') # if self.engine.phase == self.engine.PHASE_SHOWDOWN: # return self.check_showdown_winner() self.waiting_for_new_game = True self.save_game() logger.info(f'Game over! Player {self.engine.winner} won!') if self.debug: raise Exception(f'Game over: {self.engine.winner} won!') def check_showdown_winner(self): """Winner can be identified by no pot amounts but one contrib""" if 'gg' not in self.expected: logger.debug(f'gg not in expected {self.expected}') return for s, d in self.engine.data.items(): if 'in' in d['status'] and d['hand'] == self.site.HOLE_CARDS: self.check_players_pockets(s) if len(self.engine.board) != 5: logger.info(f'Still drawing on board {self.engine.board}') return pot, total = self.site.parse_pot_and_total(self.img) if pot or total: logger.debug(f'pot {pot} or total {total} has values') return contribs = self.site.parse_contribs(self.img) # can contain pot and sidepots if len(contribs) < 1: logger.debug(f'There is not a single contrib but {contribs}') return # todo support split pot winners winner = max(contribs.items(), key=itemgetter(1))[0] logger.info(f'Winner of showdown is {winner}') cmd = ['gg', winner] self.engine.do(cmd) self.finish_it() def cards(self): """Generate cards for a site""" self.site.generate_cards() def chips(self): """Generate cards for a site""" self.site.generate_chips() def calc_board_to_pocket_ratio(self): self.site.calc_board_to_pocket_ratio() def save_game(self): """Check for players that sit out, and do not save their game""" for s, d in self.engine.data.items(): if 'f' in [i['action'] for i in d['preflop']]: balance_txt = self.site.parse_balances(self.img, s, True) if balance_txt == 'sit out': d['sitout'] = True ES.save_game(self.players, self.engine.data, self.engine.site_name, self.engine.vs, self.engine.board)
def test_player_stats_on_hand(self): e = Engine( 'CoinPoker', 1, { 1: { 'name': 'joe', 'balance': 1000, 'status': 1 }, 2: { 'name': 'jane', 'balance': 1000, 'status': 1 }, 3: { 'name': 'jane', 'balance': 1000, 'status': 1 }, 4: { 'name': 'jane', 'balance': 1000, 'status': 1 }, 5: { 'name': 'jane', 'balance': 1000, 'status': 1 }, 6: { 'name': 'jane', 'balance': 1000, 'status': 1 }, }, 50, 100, 0, ) e.available_actions() # p4 e.do(['r', 100]) e.available_actions() # p5 e.do(['f']) e.available_actions() # p6 e.do(['f']) e.available_actions() # p1 e.do(['c']) e.available_actions() # p2 e.do(['c']) e.available_actions() # p3 e.do(['k']) e.available_actions() # p2 e.do(['b', 100]) e.available_actions() # p3 stats = ES.player_stats(e, e.s) # hs = res.aggregations['hs']['hs_agg']['values']['50.0'] assert len(stats['actions']) >= 4
def test_player_stats_on_hand(self): e = Engine( 'CoinPoker', 1, { 1: {'name': 'joe', 'balance': 1000, 'status': 1}, 2: {'name': 'jane', 'balance': 1000, 'status': 1}, 3: {'name': 'jane', 'balance': 1000, 'status': 1}, 4: {'name': 'jane', 'balance': 1000, 'status': 1}, 5: {'name': 'jane', 'balance': 1000, 'status': 1}, 6: {'name': 'jane', 'balance': 1000, 'status': 1}, }, 50, 100, 0, ) e.available_actions() # p4 e.do(['r', 100]) e.available_actions() # p5 e.do(['f']) e.available_actions() # p6 e.do(['f']) e.available_actions() # p1 e.do(['c']) e.available_actions() # p2 e.do(['c']) e.available_actions() # p3 e.do(['k']) e.available_actions() # p2 e.do(['b', 100]) e.available_actions() # p3 stats = ES.player_stats(e, e.s) # hs = res.aggregations['hs']['hs_agg']['values']['50.0'] assert len(stats['actions']) >= 4
class Scraper(View): """Runs a scraper for a site. Will use the site to do the scraping, and with the data calculate what action needs to be taken and pass that into the engine and MC""" PATH_DEBUG = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'debug') ACTIONS_MAP = { 'f': 'fold', 'k': 'check', 'c': 'call', 'b': 'bet', 'r': 'raise', 'a': 'allin', } def __init__(self, site_name, seats, debug=False, replay=False, observe=False): self.debug = debug logger.debug('Debug {}'.format(self.debug)) self.observe = observe logger.debug('Observing {}'.format(self.observe)) self.replay = replay logger.debug('Replay {}'.format(self.replay)) if replay: self.load_files() if site_name == 'ps': self.site = PokerStars(seats, debug) elif site_name == 'pp': self.site = PartyPoker(seats, debug) elif site_name == 'zg': self.site = Zynga(seats, debug) elif site_name == 'cp': self.site = CoinPoker(seats, debug) else: raise NotImplementedError('{} is not implemented'.format(site_name)) self.img = None # starting balance zero for ante on init self.players = { s: { 'name': 'joe', 'balance': 1000000, 'status': 1, } for s in range(1, seats + 1) } # do not add engine & mc self.btn = None # not currently in a game self.waiting_for_new_game = True # button moved self.button_moved = False # board moved to help finish phase self.board_moved = False # if tlc/button cannot be found, drop existing game after 5 seconds self.drop_game_start = None # on each player's turn, update balances self.last_thinking_seat = None # orphan call from previous first require thinking self.last_thinking_phase = None def load_files(self): """Replays through images saved during debug run""" files = [] for entry in os.scandir(self.PATH_DEBUG): if not entry.is_file() or entry.name.startswith('.'): logger.debug('skipping file {}'.format(entry.name)) continue files.append(entry.path) logger.info(f'loading {len(files)} files') self.files = sorted(files) def take_screen(self): """Get screen image Takes screen shot or load file if replaying Stats: 9.58% """ logger.info('taking screen shot') while True: if not self.replay: # 3840 x 2400 mac retina # pokerstars # img = ImageGrab.grab((1920, 600, 3840, 2400)) # coinpoker img = ImageGrab.grab((1760, 700, 3840, 2300)) img_file = os.path.join(self.PATH_DEBUG, '{}.png'.format(datetime.datetime.utcnow())) img.save(img_file) logger.debug('file saved locally to {}'.format(img_file)) else: img_path = self.files.pop(0) logger.debug('loading file: {}'.format(img_path)) img = Image.open(img_path) if not self.observe: img_full = img.convert('L') try: self.img = self.site.parse_top_left_corner(img_full) if self.drop_game_start: self.drop_game_start = None logger.info('Continuing existing game') except SiteException as e: logger.info(e) if not self.waiting_for_new_game: if not self.drop_game_start: self.drop_game_start = time.time() logger.warning('Drop game beginning...') elif time.time() - self.drop_game_start > 5: self.waiting_for_new_game = True self.button_moved = False logger.error('Game state aborted!') if self.debug: logger.warning('TLC not found in image!') time.sleep(1) continue try: btn = self.site.parse_dealer(self.img) except NoDealerButtonError as e: logger.warning(e) time.sleep(0.6) break else: if not self.btn: logger.debug('button initialised at {} on joining table'.format(btn)) self.btn = btn elif btn != self.btn: self.button_moved = True self.btn_next = btn logger.debug(f'button moved to {btn}!') self.check_board() # DONE # if self.debug: # input('$ check hands if lucky:') # for s, d in self.engine.data.items(): # if 'in' in d['status']: # self.site.parse_pocket_region(self.img, s) # always break (only continue when tlc & button found) break def check_board(self): """Check board and handles exception raised if card not identified. Card animation could be covering the cards. Instead of retrying rather return existing board""" if not hasattr(self, 'engine'): logger.info('Not checking board when no engine present') return if len(self.engine.board) >= 5: logger.info(f'Board already identified as {self.engine.board}') return logger.info('checking board...') board = self.site.parse_board(self.img) # works like this on CP # if not self.button_moved and not self.waiting_for_new_game and len(board) < len(self.engine.board): # raise BoardError('Board cannot be removed without button moving') if len(board) > len(self.engine.board): logger.debug(f'The board changed with {set(board) - set(self.engine.board)}') self.engine.board = board self.board_moved = True logger.debug('board: {}'.format(self.engine.board)) def run(self): """Run application""" while True: self.take_screen() if self.debug: self.img.show() time.sleep(1) if self.observe: continue # we are ready for a new game if self.waiting_for_new_game: logger.debug('waiting for a new game...') if self.button_moved: logger.debug('button moved and waiting for new game: create new game') self.start_new_game() else: logger.info('just waiting for new game to start...') time.sleep(1) continue # else game in progress else: logger.debug('still playing same game') if self.button_moved: logger.debug('button moved! we need to finish up what engine is') self.finish_it() elif self.engine.phase == self.engine.PHASE_SHOWDOWN: self.check_showdown_winner() time.sleep(1) elif self.engine.phase == self.engine.PHASE_GG: pot, total = self.site.parse_pot_and_total(self.img) if not pot and not total: logger.info(f'Game completed and pot allocated') self.finish_it() else: logger.info('Game completed but waiting for button move') time.sleep(0.5) else: logger.debug('button not moved, just continue loop') self.wait_player_action() logger.info('loop finished') def run_mc(self, timeout): """Runs MC analysis till timeout. This catches error where the amounts have varied too much from the current board by making the closes action""" logger.info('Running MC analysis') # if self.debug: # timeout = 0.4 if 'in' not in self.engine.data[self.site.HERO]['status']: time.sleep(0.2) else: # profiler = Profiler() # time_start = time.time() try: # if self.debug: # profiler.start() self.mc.run(timeout) # if self.debug: # profiler.stop() except EngineError as e: # if self.debug: # profiler.stop() logger.error(e) self.mc.init_tree() self.mc.run(timeout) # duration = time.time() - time_start # if not self.mc.queue.empty() and duration > timeout * 2: # with open(f'profile_{timeout}_{duration}.html', 'w') as f: # f.write(profiler.output_html()) # logger.warning(f'MC {timeout}s run took way longer at {duration}s') self.print() def wait_player_action(self): """Think a little. Always think at least 1 second after every player actioned. First detect if the phase haven't moved on by checking board cards Secondly detect if current player isn't still thinking""" self.run_mc(0.3) # todo check if gg in expected # todo then scan for foe pockets and exit # check actions till phase has caught up # board will not trigger (as cards cleanedup) as we will have button_moved flag # this works when card dealt and players still need to act # this fails when normally finished last action, then when turn comes the whole phase is # considered board phase... # check if phase from number of cards on board is what current player expected phase is if self.board_moved: # this works only if 'board_moved' (looking to catch up if card added to board) mapped_board = self.engine.BOARD_MAP[len(self.engine.board)] while self.engine.phase != mapped_board: logger.debug(f'board at: {mapped_board} and engine at {self.engine.phase}') # if second last person folded, or others allin if self.engine.phase in [self.engine.PHASE_SHOWDOWN, self.engine.PHASE_GG]: logger.info(f'Engine caught up to {mapped_board}, but gg & sd handled separately') return self.check_player_action() self.run_mc(0.3) self.board_moved = False # do not update last_thinking_phase as previous phase text still shows, giving wrong action # an allin would end here if self.engine.phase == self.engine.PHASE_SHOWDOWN: logger.debug('Game in phase showdown, not checking players') time.sleep(0.8) return # as long as someone else is thinking, run analysis logger.info('Checking parsing player...') try: current_s = self.site.parse_thinking_player(self.img) logger.debug(f'Current thinking player is {current_s}') except ThinkingPlayerError as e: logger.debug(f'Current thinking player is unknown, estimating action for {self.engine.s}') # during any phase, if pot removed, then have to loop to find winner with contrib pot, total = self.site.parse_pot_and_total(self.img) if not pot: while self.engine.phase not in [self.engine.PHASE_SHOWDOWN, self.engine.PHASE_GG] \ and self.engine.phase == self.last_thinking_phase: try: self.check_player_action(expect_text=True) except PlayerActionError as exc: # happened when player did not move, but thinking already moved. wtf return self.run_mc(0.3) if self.engine.phase == self.engine.PHASE_SHOWDOWN: self.check_showdown_winner() elif self.engine.phase == self.engine.PHASE_GG: self.finish_it() # else we can check for action considering we are in same phase elif self.last_thinking_phase == self.engine.phase: try: self.check_player_action(expect_text=True) except PlayerActionError as exc: # happened when player did not move, but thinking already moved. wtf # 2: caught thinking elapsed, but folded cards not yet removed logger.info(str(exc)) return self.run_mc(0.3) else: self.last_thinking_phase = self.engine.phase # if player is still thinking, then so can we if current_s == self.engine.q[0][0]: logger.debug(f'player to act {current_s} is still thinking, so can we...') if current_s != self.last_thinking_seat: self.check_player_name(current_s) self.check_player_balance(current_s) self.last_thinking_seat = current_s # longer thinking time for hero thinking_time = 1 if current_s == self.site.HERO else 0.4 self.run_mc(thinking_time) # player (in engine) to act is not the one thinking on screen # whilst it is not the expected player to act use the same current img to catch up else: while current_s != self.engine.q[0][0]: logger.debug(f'taking action for {self.engine.q[0][0]} as he is not thinking on screen') if self.engine.phase in [self.engine.PHASE_SHOWDOWN, self.engine.PHASE_GG]: logger.warning(f'exiting board phase as engine is now in {self.engine.phase}') return try: self.check_player_action() except (BalancesError, PlayerActionError) as e: logger.warning(e) # retry with new screen return self.run_mc(0.3) def check_player_action(self, expect_text=False): """It is certain the expected player is not thinking, thus he finished his turn: check the player action. Based on expected moves we can infer what he did Balance would change for: call, bet, raise, allin and not for: fold, check Fold is most common and easy to detect pocket cards Check (if so) is easy if balance did not change Otherwise balance has changed: balance and contrib change should match, just send bet to engine, it'll correct the action """ s = self.engine.q[0][0] phase = self.engine.phase logger.info(f'check player {s} action') logger.info(f'expecting one of {self.expected} during {phase}') if 'fold' not in self.expected: logger.error(f'fold is not in {self.expected}') raise PlayerActionError('End of game should not be here: player cannot fold') pocket = self.check_players_pockets(s) contrib = self.site.parse_contribs(self.img, s) cmd = self.site.infer_player_action(self.img, s, phase, pocket, contrib or 0, self.engine, self.engine.current_pot, self.board_moved, self.expected, expect_text) # do action, rotate, and get next actions logger.debug(f'parsed action {cmd}') action = self.engine.do(cmd) action_name = self.ACTIONS_MAP[action[0]] logger.info(f'Player {s} did {action_name} {action}') # cut tree based on action # do not have to cut tree when button moved if not self.button_moved: # self.mc.analyze_tree() child_nodes = self.mc.tree.children(self.mc.tree.root) logger.debug(f'{len(child_nodes)} child nodes on tree {self.mc.tree.root}') # logger.info('nodes:\n{}'.format(json.dumps([n.tag for n in child_nodes], indent=4, default=str))) action_nodes = [n for n in child_nodes if n.data['action'].startswith(action_name)] # create new if not action_nodes: logger.warning(f'action {action_name} not found in nodes {[n.data["action"] for n in child_nodes]}') self.mc.init_tree() logger.debug('tree recreated') # subtree else: # direct if len(action_nodes) == 1: node = action_nodes[0] self.mc.tree = self.mc.tree.subtree(node.identifier) logger.debug(f'Tree branched from single node {node.tag}') logger.debug(f'tree branched from single node {node.data}') # proximity else: nodes_diffs = {abs(n.data['amount'] - action[1]): n for n in action_nodes} node = nodes_diffs[min(nodes_diffs.keys())] self.mc.tree = self.mc.tree.subtree(node.identifier) logger.debug(f'tree recreated from closest node {node.tag}') logger.debug(f'tree recreated from closest node {node.data}') # increment traversed level if not node.tag.endswith('_{}_{}'.format(s, phase)): logger.error(f'Finished player {s} in {phase} not in subtree tag {node.tag}') self.mc.init_tree() logger.debug('get next actions') self.expected = self.engine.available_actions() def check_players_pockets(self, filter_seat=None): """Check if player has back side of pocket, otherwise check what his cards are""" pockets = {} for i in range(self.site.seats): s = i + 1 if filter_seat and filter_seat != s: continue # could already have hand # but cannot check here # as it does not pick up when hero folded # if hasattr(self, 'engine') and self.engine.data[s]['hand'] not in [['__', '__'], [' ', ' ']]: # pockets[s] = self.engine.data[s] # continue # check for back side if self.site.parse_pocket_back(self.img, s): logger.info(f'Player {s} has hole cards') pockets[s] = self.site.HOLE_CARDS continue # check if showing cards pocket = self.site.parse_pocket_cards(self.img, s) if pocket: logger.info(f'Player {s} is showing {pocket}') pockets[s] = pocket if hasattr(self, 'engine'): self.engine.data[s]['hand'] = pocket continue logger.info(f'Player {s} has no cards') if filter_seat: return pockets.get(filter_seat) return pockets def check_player_name(self, seat): """Get name hashes and set it to players""" name = self.site.parse_names(self.img, seat) if name: self.players[seat]['name'] = name logger.info(f'Player {seat} name is {name}') def check_player_balance(self, seat): """Update player balances""" balance = self.site.parse_balances(self.img, seat) if balance: self.players[seat]['balance'] = balance logger.debug(f'Player {seat} balance is {balance}') def start_new_game(self): """Dealer button moved: create new game! Get players that are still IN: some sites have balances always available, but some are blocked by text. Thus at least 2 pockets are required. Get small blind and big blind and ante. Create new engine Create new MC Not waiting for a new game anymore. """ # for joining table first time only if not self.btn: logger.info(f'init button at {self.btn_next}') self.btn = self.btn_next return logger.info('button moved: creating new game...') self.btn = self.btn_next # there must be pockets for a game to have started pockets = self.check_players_pockets() if len(pockets) < 2: logger.debug(f'Game has not started as there are {len(pockets)} pockets right now') time.sleep(0.1) return # pot and total are required for a game to have started # cannot check pot anymore for when no ante, no pot in middle :( pot, total = self.site.parse_pot_and_total(self.img) if not total: logger.debug(f'Game has not started as there are no pot or total right now') time.sleep(0.1) return # contribs are required for blinds! contribs = self.site.parse_contribs(self.img) # seems there can be 1 blind, probably when blind fell out during prev round # if len(contribs) == 1: # logger.error('Found only 1 blind. Not supported.') # self.button_moved = False # return # requires 2 blinds to get sb and bb if not len(contribs): logger.debug('Game has not started as there are no contribs right now') time.sleep(0.1) return # not doing any more checks, since if they have cards and there is a pot: game on! # self.check_player_name() # checked at flop to save time during start # always have text on it, so who cares # balances = self.site.parse_balances(self.img) vs_players = set(list(pockets.keys()) + list(contribs.keys())) ante = self.check_ante(len(vs_players), pot) sb, bb = self.check_blinds(contribs) self.pre_start(vs_players, contribs, ante) logger.debug('creating engine...') self.engine = Engine(self.site.NAME, self.btn, self.players, sb, bb, ante) # why does 'hand' return? self.expected = self.engine.available_actions() logger.debug(f'seat {self.engine.q[0][0]} available actions: {self.expected}') logger.debug('creating MC...') self.mc = MonteCarlo(engine=self.engine, hero=self.site.HERO) self.post_start(pockets) logger.info('new game created!') self.waiting_for_new_game = False self.button_moved = False self.board_moved = False def check_ante(self, vs, pot): """Check ante from contrib total before gathering/matched. Using player balances instead of recently scraped balances.""" # no pot so there is no ante paid yet if not pot: return 0 ante = pot / vs logger.debug(f'Ante {ante} from current pot {pot} and vs {vs}') if not ante.is_integer(): logger.warning(f'Ante {ante} is not integer') ante = int(round(ante)) logger.info(f'Ante: {ante}') return ante def check_blinds(self, contribs): """SB and BB from ante structure. Since hero blocks the play, this should be always found.""" # skip SB in tournament fallout if len(contribs) == 3 and contribs.get(5) == 7: logger.info('Fixed player 5 wrong contrib of 7') del contribs[5] contribs_sorted = sorted([c for c in contribs.values() if c]) if len(contribs_sorted) == 1: return None, contribs_sorted[0] sb, bb, *others = contribs_sorted logger.info(f'Blinds found: SB {sb} and BB {bb}') if sb * 2 != bb: # revert to seating order logger.warning('Smallest SB is not 1:2 to BB') sb, bb, *_ = contribs.values() # small blind is all-in if sb < bb / 2: s = list(contribs.keys())[0] self.players[s]['balance'] = sb sb = bb / 2 # small blind is all-in if bb < sb * 2: s = list(contribs.keys())[1] self.players[s]['balance'] = bb bb = sb * 2 return sb, bb def pre_start(self, vs_players, contribs, ante): """Add contribs back to balances for when engine take action it will subtract it again from matched/contrib. The status of players can be set if they have any balance/contrib""" for s in range(1, self.site.seats + 1): status = s in vs_players self.players[s]['status'] = status self.players[s]['sitout'] = False if not status: continue # balance might not be scraped as it might be obscured by text # if s in balances: # balance = balances[s] # balance_diff = self.players[s]['balance'] - balance # if balance_diff: # logger.warning(f'player {s} balance {balance} out by {balance_diff}') # self.players[s]['balance'] = balance # if player is blind, then return money for engine to subtract on init. same for ante. # BUT with coinpoker, the balances are not scraped # so no need to add it back # contrib = contribs.get(s, 0) # if contrib: # self.players[s]['balance'] += contrib # logger.debug(f'player {s} contrib {contrib} added back to balance') # if ante: # self.players[s]['balance'] += ante # logger.debug(f'player {s} ante {ante} added back to balance') logger.info('pre start done') if self.debug: logger.info('Is all the contribs/ante correct?') def post_start(self, pockets): """Post start. Engine and MC has been created. Set hero hand immediately for analysis of EV""" hero = self.site.HERO pocket = pockets.get(hero) if pocket: self.engine.data[hero]['hand'] = pocket logger.info(f'Hero (p{hero}) hand set to {pocket}') else: logger.error('No hero pocket found!') self.print() def finish_it(self): """Finish the game. Should already have winner.""" logger.info('finish the game') # todo BUG: game does get here in showdown # todo BUG: cannot go to showdown, as the game has ended long time ago, has to finish it # if not self.engine.winner: # raise GamePhaseError(f'Game does not have winner but in phase {self.engine.phase}') # if self.engine.phase == self.engine.PHASE_SHOWDOWN: # return self.check_showdown_winner() self.waiting_for_new_game = True self.save_game() logger.info(f'Game over! Player {self.engine.winner} won!') if self.debug: raise Exception(f'Game over: {self.engine.winner} won!') def check_showdown_winner(self): """Winner can be identified by no pot amounts but one contrib""" if 'gg' not in self.expected: logger.debug(f'gg not in expected {self.expected}') return for s, d in self.engine.data.items(): if 'in' in d['status'] and d['hand'] == self.site.HOLE_CARDS: self.check_players_pockets(s) if len(self.engine.board) != 5: logger.info(f'Still drawing on board {self.engine.board}') return pot, total = self.site.parse_pot_and_total(self.img) if pot or total: logger.debug(f'pot {pot} or total {total} has values') return contribs = self.site.parse_contribs(self.img) # can contain pot and sidepots if len(contribs) < 1: logger.debug(f'There is not a single contrib but {contribs}') return # todo support split pot winners winner = max(contribs.items(), key=itemgetter(1))[0] logger.info(f'Winner of showdown is {winner}') cmd = ['gg', winner] self.engine.do(cmd) self.finish_it() def cards(self): """Generate cards for a site""" self.site.generate_cards() def chips(self): """Generate cards for a site""" self.site.generate_chips() def calc_board_to_pocket_ratio(self): self.site.calc_board_to_pocket_ratio() def save_game(self): """Check for players that sit out, and do not save their game""" for s, d in self.engine.data.items(): if 'f' in [i['action'] for i in d['preflop']]: balance_txt = self.site.parse_balances(self.img, s, True) if balance_txt == 'sit out': d['sitout'] = True ES.save_game(self.players, self.engine.data, self.engine.site_name, self.engine.vs, self.engine.board)
class Game: def __init__(self, table): ''' Create an engine for this game monte carlo will use the engine instances for iterations Game will only keep track of this current game First get actions from engine, then the repr ''' self.engine = Engine(table.site_name, table.button, table.players, table.sb, table.bb) self.history = {} self.cursor = 0 @retrace.retry() def play(self): """iterative process of inputting commands from table engine saved after available actions have been executed""" while True: available_actions = self.engine.available_actions() if not available_actions: logger.info('No actions received from engine!') break self.engine.save() r = repr(self.engine) o = ' | '.join(available_actions) u = self.replay() i = input('\n\n\n' + r + '\n\n' + o + u + '\n$ ').strip() # exit? if i == 'X': return # undo? if i == 'U': self.undo() continue # redo? if i == 'R': self.replay(True) continue # analyse? if i == 'M': MonteCarlo(self.engine).run() continue cmd = i.split() # overwrite board? if cmd[0] == 'B': self.engine.board = cmd[1:] logger.info('set board to {}'.format(cmd[1:])) continue if cmd[0] == 't': s = self.engine.q[0][0] p_contrib = self.engine.data[s]['contrib'] bet_from_contrib = int(cmd[1]) - p_contrib logger.info('extracted {} bet/raise from contrib for player {}'.format(bet_from_contrib, s)) cmd = ['b', bet_from_contrib] # play self.handle_input(cmd) self.save_game() def handle_input(self, cmd): logger.info('input = {}'.format(cmd)) # send expected engine cmd e_copy = deepcopy(self.engine) self.engine.do(cmd) # action successful, save in history self.history[self.cursor] = (e_copy, cmd) logger.info('keeping history at {} (before cmd {})'.format(self.cursor, cmd)) self.cursor += 1 logger.debug('input handled and done') def save_game(self): logger.info('saving game...') game_id = ''.join(random.choice(ascii_uppercase + digits) for _ in range(8)) for s, d in self.engine.data.items(): doc = {} for phase in ['preflop', 'flop', 'turn', 'river']: for i, action_info in enumerate(d[phase]): doc['{}_{}'.format(phase, i+1)] = action_info['action'] doc['{}_{}_rvl'.format(phase, i+1)] = action_info['rvl'] if i == 0: doc['{}_aggro'.format(phase)] = action_info['aggro'] if 'bet_to_pot' in action_info: doc['{}_{}_btp'.format(phase, i+1)] = action_info['bet_to_pot'] if action_info.get('pot_odds'): doc['{}_{}_po'.format(phase, i+1)] = action_info['pot_odds'] if doc: doc.update({ 'player': self.engine.players[s]['name'], 'site': self.engine.site_name, 'game': game_id, 'vs': self.engine.vs, 'created_at': datetime.datetime.utcnow(), }) GameAction(**doc).save() logger.info('saved {}'.format(doc)) def undo(self): """Restore previous engine state (before that cmd) Since current state is last entry in history, restore second to last item""" logger.info('undo last action') if self.cursor <= 0: logger.warn('cannot restore snapshot! cursor at {}'.format(self.cursor)) return self.cursor -= 1 snapshot = self.history[self.cursor] self.engine = snapshot[0] logger.info('previous snapshot restored. cursor back at {}'.format(self.cursor)) def replay(self, force=False): """allow same commands to be replayed if in history""" if len(self.history) <= self.cursor: return '' past_state = self.history[self.cursor] if not force: return '\nReplay: {}?'.format(past_state[1]) self.handle_input(past_state[1])