BullsAndCows

Bulls and Cows with AI

AB Guess is a game to guess 4 digits with bulls and cows. the rule is here.

I build an AI program with MonteC Carlo tree search. I test the program 100 times, it takes an average of 4.52 steps to guess the number.

You can run

./game.py --game_num=5 

to compete with AI. Players with less step will win (I can’t beat my AI😂). Good luck and have fun!

Source Code: game.py

#! /usr/bin/env python3
# -*- coding utf-8 -*-
"""
-------------------------------------------------
   File Name:     game.py
   Author :       chenhao
   time:          2021/11/4 20:22
   Description :
-------------------------------------------------
"""
import collections
import logging
import abc
import math
import random
import time
import fire
from itertools import permutations
from typing import List

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s][%(filename)s:%(lineno)d]:%(message)s",
                    datefmt='%Y-%m-%d %H:%M:%S')

logger = logging.getLogger(__name__)

NUMBER_COUNT = 4
ALL_NUMBER = list(range(10))


class IPlayer:
    def __init__(self, name):
        self.name = name

    @abc.abstractmethod
    def guess(self) -> List[int]:
        pass

    def refresh(self):
        pass

    def notify(self, guess: List[int], judge_rs: dict):
        pass

    def __str__(self):
        return self.name

    def __repr__(self):
        return self.name


class RandomPlayer(IPlayer):

    def guess(self) -> List[int]:
        return random.sample(ALL_NUMBER, NUMBER_COUNT)


class Human(IPlayer):
    def guess(self) -> List[int]:
        while True:
            try:
                logger.info("input your guess")
                guess = input()
                guess = [int(e) for e in guess]
                if len(guess) != NUMBER_COUNT:
                    raise Exception()
                return guess
            except Exception as e:
                logger.error(f"invalid input:{guess}, please input again!")
        return guess


class Node:
    def __init__(self, d):
        self.n = 0
        self.v = 0
        self.d = d
        if d < NUMBER_COUNT:
            self.children: List[Node] = [Node(d + 1) for _ in range(10)]
        else:
            self.children = None

    def get_val(self, p, c=1.0):
        v = self.n / p
        d = math.log(1 / (self.v + 1))
        return v + c * d

    def get_next(self, his):
        cands = [(idx, e, e.get_val(self.n)) for idx, e in enumerate(self.children) if e.n and idx not in his]
        # logger.info(cands)
        item = max(cands, key=lambda x: x[2])
        return item

    def clear(self):
        self.n = 0
        if self.children:
            for c in self.children:
                c.clear()

    def __repr__(self):
        return f"Node(n={self.n},v={self.v},d={self.d})"

    def __str__(self):
        return self.__repr__()


def update_tree(root, cand: List[int]):
    n = root
    for idx in cand:
        n.n += 1
        n = n.children[idx]
    n.n += 1


class TreePlayer(IPlayer):

    def __init__(self, name, wait=0):
        super().__init__(name=name)
        self.root = Node(d=0)
        self.cands = list(permutations(ALL_NUMBER, NUMBER_COUNT))
        self.wait = wait
        for cand in self.cands:
            update_tree(self.root, cand)

    def refresh(self):
        self.root = Node(d=0)
        self.cands = list(permutations(ALL_NUMBER, NUMBER_COUNT))
        for cand in self.cands:
            update_tree(self.root, cand)

    def guess(self) -> List[int]:
        n = self.root
        rs = []
        for _ in range(NUMBER_COUNT):
            idx, n, v = n.get_next(his=rs)
            n.v += 1
            rs.append(idx)
        time.sleep(self.wait)
        return rs

    def notify(self, guess: List[int], judge_rs: dict):
        tmp = len(self.cands)
        self.cands = [e for e in self.cands if judge_rs2str(judge_rs) == judge_rs2str(judge(e, guess))]
        logger.info(f"cut cands from {tmp} to {len(self.cands)} after cuts")
        self.root.clear()
        for cand in self.cands:
            update_tree(self.root, cand)


def judge(ans: List[int], gs: List[int]) -> dict:
    assert len(ans) == len(gs) == NUMBER_COUNT
    a_list = [e for e in zip(ans, gs) if e[0] == e[1]]
    a = len(a_list)
    b = len(set(ans) & set(gs))
    b -= a
    return dict(a=a, b=b)


def judge_rs2str(j_rs):
    a = j_rs["a"]
    b = j_rs["b"]
    return f"{a}A{b}B"


def run_game(player, rnd=10, answer=None):
    if not answer:
        answer = random.sample(ALL_NUMBER, NUMBER_COUNT)
    player.refresh()
    for idx in range(rnd):
        logger.info(f"round:{idx + 1}")
        guess = player.guess()
        judge_rs = judge(answer, guess)
        logger.info(f"{player} guess:{guess}, judge result:{judge_rs2str(judge_rs)}")
        if guess == answer:
            break
        player.notify(guess, judge_rs)
    logger.info(f"answer is :{answer}")
    if guess == answer:
        logger.info(f"{player} win in {idx + 1} rounds!")
        return idx
    else:
        logger.info(f"{player} failed!")
        return None


def compete(players, game_num, rnd=10, base_score=10):
    answers = [random.sample(ALL_NUMBER, NUMBER_COUNT) for _ in range(game_num)]
    score_board = collections.defaultdict(int)
    for g in range(game_num):
        logger.info(f"game:{g + 1}")
        for p in players:
            logger.info(f"player {p} try")
            s = run_game(player=p, rnd=rnd, answer=answers[g])
            s = base_score - s if s is not None else 0
            score_board[p] += s
            logger.info("press any key to select next player")
            _ = input()
        logger.info(f"current score board:{dict(score_board)}")
        logger.info("press any key to next game")
        _ = input()

    return score_board


def compete_with_ai(game_num=3):
    human = Human("Human")
    ai = TreePlayer("AI", wait=2)
    players = [human, ai]
    logger.info(f"Human Vs AI with {game_num} games")
    score_board = compete(players=players, game_num=game_num)
    logger.info("final score board:{}")
    logger.info(score_board)


def test_avg_step(test_num=100):
    ai = TreePlayer("AI", wait=0)
    steps = []
    for _ in range(test_num):
        steps.append(run_game(ai, rnd=10))
    avg = sum(steps) / len(steps)
    logger.info(f"{ai} avg cost{avg:.3f} steps with {test_num} tests")


if __name__ == '__main__':
    fire.Fire(compete_with_ai)