57
\$\begingroup\$

Tournament over!

The tournament is now over! The final simulation was run during the night, a total of \$3*10^8\$ games. The winner is Christian Sievers with his bot OptFor2X. Christian Sievers also managed to secure the second place with Rebel. Congratulations! Below you can see the official high score list for the tournament.

If you still want to play the game, you are more than welcome to use the controller posted below, and to use the code in it to create your own game.

Dice

I was invited to play a game of dice which I had never heard of. The rules were simple, yet I think it would be perfect for a KotH challenge.

The rules

The start of the game

The die goes around the table, and each time it is your turn, you get to throw the die as many times as you want. However, you have to throw it at least once. You keep track of the sum of all throws for your round. If you choose to stop, the score for the round is added to your total score.

So why would you ever stop throwing the die? Because if you get 6, your score for the entire round becomes zero, and the die is passed on. Thus, the initial goal is to increase your score as quickly as possible.

Who is the winner?

When the first player around the table reaches 40 points or more, the last round starts. Once the last round has started, everyone except the person who initiated the last round gets one more turn.

The rules for the last round is the same as for any other round. You choose to keep throwing or to stop. However, you know that you have no chance of winning if you don't get a higher score than those before you on the last round. But if you keep going too far, then you might get a 6.

However, there's one more rule to take into consideration. If your current total score (your previous score + your current score for the round) is 40 or more, and you hit a 6, your total score is set to 0. That means that you have to start all over. If you hit a 6 when your current total score is 40 or more, the game continues as normal, except that you're now in last place. The last round is not triggered when your total score is reset. You could still win the round, but it does become more challenging.

The winner is the player with the highest score once the last round is over. If two or more players share the same score, they will all be counted as victors.

An added rule is that the game continues for a maximum of 200 rounds. This is to prevent cases where multiple bots basically keep throwing until they hit 6 to stay at their current score. Once the 199th round is passed, last_round is set to true, and one more round is played. If the game goes to 200 rounds, the bot (or bots) with the highest score is the winner, even if they do not have 40 points or more.

Recap

  • Each round you keep throwing the die until you choose to stop or you get a 6
  • You must throw the die once (if your first throw is a 6, your round is immediately over)
  • If you get a 6, your current score is set to 0 (not your total score)
  • You add your current score to your total score after each round
  • When a bot ends their turn resulting in a total score of at least 40, everyone else gets a last turn
  • If your current total score is \$\geq 40\$ and you get a 6, your total score is set to 0 and your round is over
  • The last round is not triggered when the above occurs
  • The person with the highest total score after the last round is the winner
  • In case there are multiple winners, all will be counted as winners
  • The game lasts for a maximum of 200 rounds

Clarification of the scores

  • Total score: the score that you have saved from previous rounds
  • Current score: the score for the current round
  • Current total score: the sum of the two scores above

How do you participate

To participate in this KotH challenge, you should write a Python class which inherits from Bot. You should implement the function: make_throw(self, scores, last_round). That function will be called once it is your turn, and your first throw was not a 6. To keep throwing, you should yield True. To stop throwing, you should yield False. After each throw, the parent function update_state is called. Thus, you have access to your throws for the current round using the variable self.current_throws. You also have access to your own index using self.index. Thus, to see your own total score you would use scores[self.index]. You could also access the end_score for the game by using self.end_score, but you can safely assume that it will be 40 for this challenge.

You are allowed to create helper functions inside your class. You may also override functions existing in the Bot parent class, e.g. if you want to add more class properties. You are not allowed to modify the state of the game in any way except yielding True or False.

You're free to seek inspiration from this post, and copy any of the two bots that I've included here. However, I'm afraid that they're not particularly effective...

On allowing other languages

In both the sandbox and on The Nineteenth Byte, we have had discussions about allowing submissions in other languages. After reading about such implementations, and hearing arguments from both sides, I have decided to restrict this challenge to Python only. This is due to two factors: the time required to support multiple languages, and the randomness of this challenge requiring a high number of iterations to reach stability. I hope that you will still participate, and if you want to learn some Python for this challenge, I'll try to be available in the chat as often as possible.

For any questions that you might have, you can write in the chat room for this challenge. See you there!

Rules

  • Sabotage is allowed, and encouraged. That is, sabotage against other players
  • Any attempt to tinker with the controller, run-time or other submissions will be disqualified. All submissions should only work with the inputs and storage they are given.
  • Any bot which uses more than 500MB memory to make its decision will be disqualified (if you need that much memory you should rethink your choices)
  • A bot must not implement the exact same strategy as an existing one, intentionally or accidentally.
  • You are allowed to update your bot during the time of the challenge. However, you could also post another bot if your approach is different.

Example

class GoToTenBot(Bot):
    def make_throw(self, scores, last_round):
        while sum(self.current_throws) < 10:
            yield True
        yield False

This bot will keep going until it has a score of at least 10 for the round, or it throws a 6. Note that you don't need any logic to handle throwing 6. Also note that if your first throw is a 6, make_throw is never called, since your round is immediately over.

For those who are new to Python (and new to the yield concept), but want to give this a go, the yield keyword is similar to a return in some ways, but different in other ways. You can read about the concept here. Basically, once you yield, your function will stop, and the value you yielded will be sent back to the controller. There, the controller handles its logic until it is time for your bot to make another decision. Then the controller sends you the dice throw, and your make_throw function will continue executing right where if stopped before, basically on the line after the previous yield statement.

This way, the game controller can update the state without requiring a separate bot function call for each dice throw.

Specification

You may use any Python library available in pip. To ensure that I'll be able to get a good average, you have a 100 millisecond time limit per round. I'd be really happy if your script was way faster than that, so that I can run more rounds.

Evaluation

To find the winner, I will take all bots and run them in random groups of 8. If there are fewer than 8 classes submitted, I will run them in random groups of 4 to avoid always having all bots in each round. I will run simulations for about 8 hours, and the winner will be the bot with the highest win percentage. I will run start the final simulations at the start of 2019, giving you all Christmas to code your bots! The preliminary final date is January 4th, but if that's too little time I can change it to a later date.

Until then, I'll try to make a daily simulation using 30-60 minutes of CPU time, and updating the score board. This will not be the official score, but it will serve as a guide to see which bots perform the best. However, with Christmas coming up, I hope you can understand that I won't be available at all times. I'll do my best to run simulations and answer any questions related to the challenge.

Test it yourself

If you want to run your own simulations, here's the full code to the controller running the simulation, including two example bots.

Controller

Here's the updated controller for this challenge. It supports ANSI outputs, multi-threading, and collects additional stats thanks to AKroell! When I make changes to the controller, I'll update the post once documentation is complete.

Thanks to BMO, the controller is now able to download all bots from this post using the -d flag. Other functionality is unchanged in this version. This should ensure that all of your latest changes are simulated as soon as possible!

#!/usr/bin/env python3
import re
import json
import math
import random
import requests
import sys
import time
from numpy import cumsum

from collections import defaultdict
from html import unescape
from lxml import html
from multiprocessing import Pool
from os import path, rename, remove
from sys import stderr
from time import strftime

# If you want to see what each bot decides, set this to true
# Should only be used with one thread and one game
DEBUG = False
# If your terminal supports ANSI, try setting this to true
ANSI = False
# File to keep base class and own bots
OWN_FILE = 'forty_game_bots.py'
# File where to store the downloaded bots
AUTO_FILE = 'auto_bots.py'
# If you want to use up all your quota & re-download all bots
DOWNLOAD = False
# If you want to ignore a specific user's bots (eg. your own bots): add to list
IGNORE = []
# The API-request to get all the bots
URL = "https://api.stackexchange.com/2.2/questions/177765/answers?page=%s&pagesize=100&order=desc&sort=creation&site=codegolf&filter=!bLf7Wx_BfZlJ7X"


def print_str(x, y, string):
    print("\033["+str(y)+";"+str(x)+"H"+string, end = "", flush = True)

class bcolors:
    WHITE = '\033[0m'
    GREEN = '\033[92m'
    BLUE = '\033[94m'
    YELLOW = '\033[93m'
    RED = '\033[91m'
    ENDC = '\033[0m'

# Class for handling the game logic and relaying information to the bots
class Controller:

    def __init__(self, bots_per_game, games, bots, thread_id):
        """Initiates all fields relevant to the simulation

        Keyword arguments:
        bots_per_game -- the number of bots that should be included in a game
        games -- the number of games that should be simulated
        bots -- a list of all available bot classes
        """
        self.bots_per_game = bots_per_game
        self.games = games
        self.bots = bots
        self.number_of_bots = len(self.bots)
        self.wins = defaultdict(int)
        self.played_games = defaultdict(int)
        self.bot_timings = defaultdict(float)
        # self.wins = {bot.__name__: 0 for bot in self.bots}
        # self.played_games = {bot.__name__: 0 for bot in self.bots}
        self.end_score = 40
        self.thread_id = thread_id
        self.max_rounds = 200
        self.timed_out_games = 0
        self.tied_games = 0
        self.total_rounds = 0
        self.highest_round = 0
        #max, avg, avg_win, throws, success, rounds
        self.highscore = defaultdict(lambda:[0, 0, 0, 0, 0, 0])
        self.winning_scores = defaultdict(int)
        # self.highscore = {bot.__name__: [0, 0, 0] for bot in self.bots}

    # Returns a fair dice throw
    def throw_die(self):
        return random.randint(1,6)
    # Print the current game number without newline
    def print_progress(self, progress):
        length = 50
        filled = int(progress*length)
        fill = "="*filled
        space = " "*(length-filled)
        perc = int(100*progress)
        if ANSI:
            col = [
                bcolors.RED, 
                bcolors.YELLOW, 
                bcolors.WHITE, 
                bcolors.BLUE, 
                bcolors.GREEN
            ][int(progress*4)]

            end = bcolors.ENDC
            print_str(5, 8 + self.thread_id, 
                "\t%s[%s%s] %3d%%%s" % (col, fill, space, perc, end)
            )
        else:
            print(
                "\r\t[%s%s] %3d%%" % (fill, space, perc),
                flush = True, 
                end = ""
            )

    # Handles selecting bots for each game, and counting how many times
    # each bot has participated in a game
    def simulate_games(self):
        for game in range(self.games):
            if self.games > 100:
                if game % (self.games // 100) == 0 and not DEBUG:
                    if self.thread_id == 0 or ANSI:
                        progress = (game+1) / self.games
                        self.print_progress(progress)
            game_bot_indices = random.sample(
                range(self.number_of_bots), 
                self.bots_per_game
            )

            game_bots = [None for _ in range(self.bots_per_game)]
            for i, bot_index in enumerate(game_bot_indices):
                self.played_games[self.bots[bot_index].__name__] += 1
                game_bots[i] = self.bots[bot_index](i, self.end_score)

            self.play(game_bots)
        if not DEBUG and (ANSI or self.thread_id == 0):
            self.print_progress(1)

        self.collect_results()

    def play(self, game_bots):
        """Simulates a single game between the bots present in game_bots

        Keyword arguments:
        game_bots -- A list of instantiated bot objects for the game
        """
        last_round = False
        last_round_initiator = -1
        round_number = 0
        game_scores = [0 for _ in range(self.bots_per_game)]


        # continue until one bot has reached end_score points
        while not last_round:
            for index, bot in enumerate(game_bots):
                t0 = time.clock()
                self.single_bot(index, bot, game_scores, last_round)
                t1 = time.clock()
                self.bot_timings[bot.__class__.__name__] += t1-t0

                if game_scores[index] >= self.end_score and not last_round:
                    last_round = True
                    last_round_initiator = index
            round_number += 1

            # maximum of 200 rounds per game
            if round_number > self.max_rounds - 1:
                last_round = True
                self.timed_out_games += 1
                # this ensures that everyone gets their last turn
                last_round_initiator = self.bots_per_game

        # make sure that all bots get their last round
        for index, bot in enumerate(game_bots[:last_round_initiator]):
            t0 = time.clock()
            self.single_bot(index, bot, game_scores, last_round)
            t1 = time.clock()
            self.bot_timings[bot.__class__.__name__] += t1-t0

        # calculate which bots have the highest score
        max_score = max(game_scores)
        nr_of_winners = 0
        for i in range(self.bots_per_game):
            bot_name = game_bots[i].__class__.__name__
            # average score per bot
            self.highscore[bot_name][1] += game_scores[i]
            if self.highscore[bot_name][0] < game_scores[i]:
                # maximum score per bot
                self.highscore[bot_name][0] = game_scores[i]
            if game_scores[i] == max_score:
                # average winning score per bot
                self.highscore[bot_name][2] += game_scores[i]
                nr_of_winners += 1
                self.wins[bot_name] += 1
        if nr_of_winners > 1:
            self.tied_games += 1
        self.total_rounds += round_number
        self.highest_round = max(self.highest_round, round_number)
        self.winning_scores[max_score] += 1

    def single_bot(self, index, bot, game_scores, last_round):
        """Simulates a single round for one bot

        Keyword arguments:
        index -- The player index of the bot (e.g. 0 if the bot goes first)
        bot -- The bot object about to be simulated
        game_scores -- A list of ints containing the scores of all players
        last_round -- Boolean describing whether it is currently the last round
        """

        current_throws = [self.throw_die()]
        if current_throws[-1] != 6:

            bot.update_state(current_throws[:])
            for throw in bot.make_throw(game_scores[:], last_round):
                # send the last die cast to the bot
                if not throw:
                    break
                current_throws.append(self.throw_die())
                if current_throws[-1] == 6:
                    break
                bot.update_state(current_throws[:])

        if current_throws[-1] == 6:
            # reset total score if running total is above end_score
            if game_scores[index] + sum(current_throws) - 6 >= self.end_score:
                game_scores[index] = 0
        else:
            # add to total score if no 6 is cast
            game_scores[index] += sum(current_throws)

        if DEBUG:
            desc = "%d: Bot %24s plays %40s with " + \
            "scores %30s and last round == %5s"
            print(desc % (index, bot.__class__.__name__, 
                current_throws, game_scores, last_round))

        bot_name = bot.__class__.__name__
        # average throws per round
        self.highscore[bot_name][3] += len(current_throws)
        # average success rate per round
        self.highscore[bot_name][4] += int(current_throws[-1] != 6)
        # total number of rounds
        self.highscore[bot_name][5] += 1


    # Collects all stats for the thread, so they can be summed up later
    def collect_results(self):
        self.bot_stats = {
            bot.__name__: [
                self.wins[bot.__name__],
                self.played_games[bot.__name__],
                self.highscore[bot.__name__]
            ]
        for bot in self.bots}


# 
def print_results(total_bot_stats, total_game_stats, elapsed_time):
    """Print the high score after the simulation

    Keyword arguments:
    total_bot_stats -- A list containing the winning stats for each thread
    total_game_stats -- A list containing controller stats for each thread
    elapsed_time -- The number of seconds that it took to run the simulation
    """

    # Find the name of each bot, the number of wins, the number
    # of played games, and the win percentage
    wins = defaultdict(int)
    played_games = defaultdict(int)
    highscores = defaultdict(lambda: [0, 0, 0, 0, 0, 0])
    bots = set()
    timed_out_games = sum(s[0] for s in total_game_stats)
    tied_games = sum(s[1] for s in total_game_stats)
    total_games = sum(s[2] for s in total_game_stats)
    total_rounds = sum(s[4] for s in total_game_stats)
    highest_round = max(s[5] for s in total_game_stats)
    average_rounds = total_rounds / total_games
    winning_scores = defaultdict(int)
    bot_timings = defaultdict(float)

    for stats in total_game_stats:
        for score, count in stats[6].items():
            winning_scores[score] += count
    percentiles = calculate_percentiles(winning_scores, total_games)


    for thread in total_bot_stats:
        for bot, stats in thread.items():
            wins[bot] += stats[0]
            played_games[bot] += stats[1]

            highscores[bot][0] = max(highscores[bot][0], stats[2][0])       
            for i in range(1, 6):
                highscores[bot][i] += stats[2][i]
            bots.add(bot)

    for bot in bots:
        bot_timings[bot] += sum(s[3][bot] for s in total_game_stats)

    bot_stats = [[bot, wins[bot], played_games[bot], 0] for bot in bots]

    for i, bot in enumerate(bot_stats):
        bot[3] = 100 * bot[1] / bot[2] if bot[2] > 0 else 0
        bot_stats[i] = tuple(bot)

    # Sort the bots by their winning percentage
    sorted_scores = sorted(bot_stats, key=lambda x: x[3], reverse=True)
    # Find the longest class name for any bot
    max_len = max([len(b[0]) for b in bot_stats])

    # Print the highscore list
    if ANSI:
        print_str(0, 9 + threads, "")
    else:
        print("\n")


    sim_msg = "\tSimulation or %d games between %d bots " + \
        "completed in %.1f seconds"
    print(sim_msg % (total_games, len(bots), elapsed_time))
    print("\tEach game lasted for an average of %.2f rounds" % average_rounds)
    print("\t%d games were tied between two or more bots" % tied_games)
    print("\t%d games ran until the round limit, highest round was %d\n"
        % (timed_out_games, highest_round))

    print_bot_stats(sorted_scores, max_len, highscores)
    print_score_percentiles(percentiles)
    print_time_stats(bot_timings, max_len)

def calculate_percentiles(winning_scores, total_games):
    percentile_bins = 10000
    percentiles = [0 for _ in range(percentile_bins)]
    sorted_keys = list(sorted(winning_scores.keys()))
    sorted_values = [winning_scores[key] for key in sorted_keys]
    cumsum_values = list(cumsum(sorted_values))
    i = 0

    for perc in range(percentile_bins):
        while cumsum_values[i] < total_games * (perc+1) / percentile_bins:
            i += 1
        percentiles[perc] = sorted_keys[i] 
    return percentiles

def print_score_percentiles(percentiles):
    n = len(percentiles)
    show = [.5, .75, .9, .95, .99, .999, .9999]
    print("\t+----------+-----+")
    print("\t|Percentile|Score|")
    print("\t+----------+-----+")
    for p in show:
        print("\t|%10.2f|%5d|" % (100*p, percentiles[int(p*n)]))
    print("\t+----------+-----+")
    print()


def print_bot_stats(sorted_scores, max_len, highscores):
    """Print the stats for the bots

    Keyword arguments:
    sorted_scores -- A list containing the bots in sorted order
    max_len -- The maximum name length for all bots
    highscores -- A dict with additional stats for each bot
    """
    delimiter_format = "\t+%s%s+%s+%s+%s+%s+%s+%s+%s+%s+"
    delimiter_args = ("-"*(max_len), "", "-"*4, "-"*8, 
        "-"*8, "-"*6, "-"*6, "-"*7, "-"*6, "-"*8)
    delimiter_str = delimiter_format % delimiter_args
    print(delimiter_str)
    print("\t|%s%s|%4s|%8s|%8s|%6s|%6s|%7s|%6s|%8s|" 
        % ("Bot", " "*(max_len-3), "Win%", "Wins", 
            "Played", "Max", "Avg", "Avg win", "Throws", "Success%"))
    print(delimiter_str)

    for bot, wins, played, score in sorted_scores:
        highscore = highscores[bot]
        bot_max_score = highscore[0]
        bot_avg_score = highscore[1] / played
        bot_avg_win_score = highscore[2] / max(1, wins)
        bot_avg_throws = highscore[3] / highscore[5]
        bot_success_rate = 100 * highscore[4] / highscore[5]

        space_fill = " "*(max_len-len(bot))
        format_str = "\t|%s%s|%4.1f|%8d|%8d|%6d|%6.2f|%7.2f|%6.2f|%8.2f|"
        format_arguments = (bot, space_fill, score, wins, 
            played, bot_max_score, bot_avg_score,
            bot_avg_win_score, bot_avg_throws, bot_success_rate)
        print(format_str % format_arguments)

    print(delimiter_str)
    print()

def print_time_stats(bot_timings, max_len):
    """Print the execution time for all bots

    Keyword arguments:
    bot_timings -- A dict containing information about timings for each bot
    max_len -- The maximum name length for all bots
    """
    total_time = sum(bot_timings.values())
    sorted_times = sorted(bot_timings.items(), 
        key=lambda x: x[1], reverse = True)

    delimiter_format = "\t+%s+%s+%s+"
    delimiter_args = ("-"*(max_len), "-"*7, "-"*5)
    delimiter_str = delimiter_format % delimiter_args
    print(delimiter_str)

    print("\t|%s%s|%7s|%5s|" % ("Bot", " "*(max_len-3), "Time", "Time%"))
    print(delimiter_str)
    for bot, bot_time in sorted_times:
        space_fill = " "*(max_len-len(bot))
        perc = 100 * bot_time / total_time
        print("\t|%s%s|%7.2f|%5.1f|" % (bot, space_fill, bot_time, perc))
    print(delimiter_str)
    print() 


def run_simulation(thread_id, bots_per_game, games_per_thread, bots):
    """Used by multithreading to run the simulation in parallel

    Keyword arguments:
    thread_id -- A unique identifier for each thread, starting at 0
    bots_per_game -- How many bots should participate in each game
    games_per_thread -- The number of games to be simulated
    bots -- A list of all bot classes available
    """
    try:
        controller = Controller(bots_per_game, 
            games_per_thread, bots, thread_id)
        controller.simulate_games()
        controller_stats = (
            controller.timed_out_games,
            controller.tied_games,
            controller.games,
            controller.bot_timings,
            controller.total_rounds,
            controller.highest_round,
            controller.winning_scores
        )
        return (controller.bot_stats, controller_stats)
    except KeyboardInterrupt:
        return {}


# Prints the help for the script
def print_help():
    print("\nThis is the controller for the PPCG KotH challenge " + \
        "'A game of dice, but avoid number 6'")
    print("For any question, send a message to maxb\n")
    print("Usage: python %s [OPTIONS]" % sys.argv[0])
    print("\n  -n\t\tthe number of games to simluate")
    print("  -b\t\tthe number of bots per round")
    print("  -t\t\tthe number of threads")
    print("  -d\t--download\tdownload all bots from codegolf.SE")
    print("  -A\t--ansi\trun in ANSI mode, with prettier printing")
    print("  -D\t--debug\trun in debug mode. Sets to 1 thread, 1 game")
    print("  -h\t--help\tshow this help\n")

# Make a stack-API request for the n-th page
def req(n):
    req = requests.get(URL % n)
    req.raise_for_status()
    return req.json()

# Pull all the answers via the stack-API
def get_answers():
    n = 1
    api_ans = req(n)
    answers = api_ans['items']
    while api_ans['has_more']:
        n += 1
        if api_ans['quota_remaining']:
            api_ans = req(n)
            answers += api_ans['items']
        else:
            break

    m, r = api_ans['quota_max'], api_ans['quota_remaining']
    if 0.1 * m > r:
        print(" > [WARN]: only %s/%s API-requests remaining!" % (r,m), file=stderr)

    return answers


def download_players():
    players = {}

    for ans in get_answers():
        name = unescape(ans['owner']['display_name'])
        bots = []

        root = html.fromstring('<body>%s</body>' % ans['body'])
        for el in root.findall('.//code'):
            code = el.text
            if re.search(r'^class \w+\(\w*Bot\):.*$', code, flags=re.MULTILINE):
                bots.append(code)

        if not bots:
            print(" > [WARN] user '%s': couldn't locate any bots" % name, file=stderr)
        elif name in players:
            players[name] += bots
        else:
            players[name] = bots

    return players


# Download all bots from codegolf.stackexchange.com
def download_bots():
    print('pulling bots from the interwebs..', file=stderr)
    try:
        players = download_players()
    except Exception as ex:
        print('FAILED: (%s)' % ex, file=stderr)
        exit(1)

    if path.isfile(AUTO_FILE):
        print(' > move: %s -> %s.old' % (AUTO_FILE,AUTO_FILE), file=stderr)
        if path.exists('%s.old' % AUTO_FILE):
            remove('%s.old' % AUTO_FILE)
        rename(AUTO_FILE, '%s.old' % AUTO_FILE)

    print(' > writing players to %s' % AUTO_FILE, file=stderr)
    f = open(AUTO_FILE, 'w+', encoding='utf8')
    f.write('# -*- coding: utf-8 -*- \n')
    f.write('# Bots downloaded from https://codegolf.stackexchange.com/questions/177765 @ %s\n\n' % strftime('%F %H:%M:%S'))
    with open(OWN_FILE, 'r') as bfile:
        f.write(bfile.read()+'\n\n\n# Auto-pulled bots:\n\n')
    for usr in players:
        if usr not in IGNORE:
            for bot in players[usr]:
                f.write('# User: %s\n' % usr)
                f.write(bot+'\n\n')
    f.close()

    print('OK: pulled %s bots' % sum(len(bs) for bs in players.values()))


if __name__ == "__main__":

    games = 10000
    bots_per_game = 8
    threads = 4

    for i, arg in enumerate(sys.argv):
        if arg == "-n" and len(sys.argv) > i+1 and sys.argv[i+1].isdigit():
            games = int(sys.argv[i+1])
        if arg == "-b" and len(sys.argv) > i+1 and sys.argv[i+1].isdigit():
            bots_per_game = int(sys.argv[i+1])
        if arg == "-t" and len(sys.argv) > i+1 and sys.argv[i+1].isdigit():
            threads = int(sys.argv[i+1])
        if arg == "-d" or arg == "--download":
            DOWNLOAD = True
        if arg == "-A" or arg == "--ansi":
            ANSI = True
        if arg == "-D" or arg == "--debug":
            DEBUG = True
        if arg == "-h" or arg == "--help":
            print_help()
            quit()
    if ANSI:
        print(chr(27) + "[2J", flush =  True)
        print_str(1,3,"")
    else:
        print()

    if DOWNLOAD:
        download_bots()
        exit() # Before running other's code, you might want to inspect it..

    if path.isfile(AUTO_FILE):
        exec('from %s import *' % AUTO_FILE[:-3])
    else:
        exec('from %s import *' % OWN_FILE[:-3])

    bots = get_all_bots()

    if bots_per_game > len(bots):
        bots_per_game = len(bots)
    if bots_per_game < 2:
        print("\tAt least 2 bots per game is needed")
        bots_per_game = 2
    if games <= 0:
        print("\tAt least 1 game is needed")
        games = 1
    if threads <= 0:
        print("\tAt least 1 thread is needed")
        threads = 1
    if DEBUG:
        print("\tRunning in debug mode, with 1 thread and 1 game")
        threads = 1
        games = 1

    games_per_thread = math.ceil(games / threads)

    print("\tStarting simulation with %d bots" % len(bots))
    sim_str = "\tSimulating %d games with %d bots per game"
    print(sim_str % (games, bots_per_game))
    print("\tRunning simulation on %d threads" % threads)
    if len(sys.argv) == 1:
        print("\tFor help running the script, use the -h flag")
    print()

    with Pool(threads) as pool:
        t0 = time.time()
        results = pool.starmap(
            run_simulation, 
            [(i, bots_per_game, games_per_thread, bots) for i in range(threads)]
        )
        t1 = time.time()
        if not DEBUG:
            total_bot_stats = [r[0] for r in results]
            total_game_stats = [r[1] for r in results]
            print_results(total_bot_stats, total_game_stats, t1-t0)

If you want access to the original controller for this challenge, it is available in the edit history. The new controller has the exact same logic for running the game, the only difference is performance, stat collection and prettier printing.

Bots

On my machine, the bots are kept in the file forty_game_bots.py. If you use any other name for the file, you must update the import statement at the top of the controller.

import sys, inspect
import random
import numpy as np

# Returns a list of all bot classes which inherit from the Bot class
def get_all_bots():
    return Bot.__subclasses__()

# The parent class for all bots
class Bot:

    def __init__(self, index, end_score):
        self.index = index
        self.end_score = end_score

    def update_state(self, current_throws):
        self.current_throws = current_throws

    def make_throw(self, scores, last_round):
        yield False


class ThrowTwiceBot(Bot):

    def make_throw(self, scores, last_round):
        yield True
        yield False

class GoToTenBot(Bot):

    def make_throw(self, scores, last_round):
        while sum(self.current_throws) < 10:
            yield True
        yield False

Running the simulation

To run a simulation, save both code snippets posted above to two separate files. I have saved them as forty_game_controller.py and forty_game_bots.py. Then you simply use python forty_game_controller.py or python3 forty_game_controller.py depending on your Python configuration. Follow the instructions from there if you want to configure your simulation further, or try tinkering with the code if you want.

Game stats

If you're making a bot that aims for a certain score without taking other bots into consideration, these are the winning score percentiles:

+----------+-----+
|Percentile|Score|
+----------+-----+
|     50.00|   44|
|     75.00|   48|
|     90.00|   51|
|     95.00|   54|
|     99.00|   58|
|     99.90|   67|
|     99.99|  126|
+----------+-----+

High scores

As more answers are posted, I'll try to keep this list updated. The contents of the list will always be from the latest simulation. The bots ThrowTwiceBot and GoToTenBot are the bots from the code above, and are used as reference. I did a simulation with 10^8 games, which took about 1 hour. Then I saw that the game reached stability compared to my runs with 10^7 games. However, with people still posting bots, I won't do any longer simulations until the frequency of responses has gone down.

I try to add all new bots and add any changes that you've made to existing bots. If it seems that I have missed your bot or any new changes you have, write in the chat and I'll make sure to have your very latest version in the next simulation.

We now have more stats for each bot thanks to AKroell! The three new columns contain the maximum score across all games, the average score per game, and the average score when winning for each bot.

As pointed out in the comments, there was an issue with the game logic which made bots that had a higher index within a game get an extra round in some cases. This has been fixed now, and the scores below reflect this.

Simulation or 300000000 games between 49 bots completed in 35628.7 seconds
Each game lasted for an average of 3.73 rounds
29127662 games were tied between two or more bots
0 games ran until the round limit, highest round was 22

+-----------------------+----+--------+--------+------+------+-------+------+--------+
|Bot                    |Win%|    Wins|  Played|   Max|   Avg|Avg win|Throws|Success%|
+-----------------------+----+--------+--------+------+------+-------+------+--------+
|OptFor2X               |21.6|10583693|48967616|    99| 20.49|  44.37|  4.02|   33.09|
|Rebel                  |20.7|10151261|48977862|   104| 21.36|  44.25|  3.90|   35.05|
|Hesitate               |20.3| 9940220|48970815|   105| 21.42|  44.23|  3.89|   35.11|
|EnsureLead             |20.3| 9929074|48992362|   101| 20.43|  44.16|  4.50|   25.05|
|StepBot                |20.2| 9901186|48978938|    96| 20.42|  43.47|  4.56|   24.06|
|BinaryBot              |20.1| 9840684|48981088|   115| 21.01|  44.48|  3.85|   35.92|
|Roll6Timesv2           |20.1| 9831713|48982301|   101| 20.83|  43.53|  4.37|   27.15|
|AggressiveStalker      |19.9| 9767637|48979790|   110| 20.46|  44.86|  3.90|   35.04|
|FooBot                 |19.9| 9740900|48980477|   100| 22.03|  43.79|  3.91|   34.79|
|QuotaBot               |19.9| 9726944|48980023|   101| 19.96|  44.95|  4.50|   25.03|
|BePrepared             |19.8| 9715461|48978569|   112| 18.68|  47.58|  4.30|   28.31|
|AdaptiveRoller         |19.7| 9659023|48982819|   107| 20.70|  43.27|  4.51|   24.81|
|GoTo20Bot              |19.6| 9597515|48973425|   108| 21.15|  43.24|  4.44|   25.98|
|Gladiolen              |19.5| 9550368|48970506|   107| 20.16|  45.31|  3.91|   34.81|
|LastRound              |19.4| 9509645|48988860|   100| 20.45|  43.50|  4.20|   29.98|
|BrainBot               |19.4| 9500957|48985984|   105| 19.26|  45.56|  4.46|   25.71|
|GoTo20orBestBot        |19.4| 9487725|48975944|   104| 20.98|  44.09|  4.46|   25.73|
|Stalker                |19.4| 9485631|48969437|   103| 20.20|  45.34|  3.80|   36.62|
|ClunkyChicken          |19.1| 9354294|48972986|   112| 21.14|  45.44|  3.57|   40.48|
|FortyTeen              |18.8| 9185135|48980498|   107| 20.90|  46.77|  3.88|   35.32|
|Crush                  |18.6| 9115418|48985778|    96| 14.82|  43.08|  5.15|   14.15|
|Chaser                 |18.6| 9109636|48986188|   107| 19.52|  45.62|  4.06|   32.39|
|MatchLeaderBot         |16.6| 8122985|48979024|   104| 18.61|  45.00|  3.20|   46.70|
|Ro                     |16.5| 8063156|48972140|   108| 13.74|  48.24|  5.07|   15.44|
|TakeFive               |16.1| 7906552|48994992|   100| 19.38|  44.68|  3.36|   43.96|
|RollForLuckBot         |16.1| 7901601|48983545|   109| 17.30|  50.54|  4.72|   21.30|
|Alpha                  |15.5| 7584770|48985795|   104| 17.45|  46.64|  4.04|   32.67|
|GoHomeBot              |15.1| 7418649|48974928|    44| 13.23|  41.41|  5.49|    8.52|
|LeadBy5Bot             |15.0| 7354458|48987017|   110| 17.15|  46.95|  4.13|   31.16|
|NotTooFarBehindBot     |15.0| 7338828|48965720|   115| 17.75|  45.03|  2.99|   50.23|
|GoToSeventeenRollTenBot|14.1| 6900832|48976440|   104| 10.26|  49.25|  5.68|    5.42|
|LizduadacBot           |14.0| 6833125|48978161|    96|  9.67|  51.35|  5.72|    4.68|
|TleilaxuBot            |13.5| 6603853|48985292|   137| 15.25|  45.05|  4.27|   28.80|
|BringMyOwn_dice        |12.0| 5870328|48974969|    44| 21.27|  41.47|  4.24|   29.30|
|SafetyNet              |11.4| 5600688|48987015|    98| 15.81|  45.03|  2.41|   59.84|
|WhereFourArtThouChicken|10.5| 5157324|48976428|    64| 22.38|  47.39|  3.59|   40.19|
|ExpectationsBot        | 9.0| 4416154|48976485|    44| 24.40|  41.55|  3.58|   40.41|
|OneStepAheadBot        | 8.4| 4132031|48975605|    50| 18.24|  46.02|  3.20|   46.59|
|GoBigEarly             | 6.6| 3218181|48991348|    49| 20.77|  42.95|  3.90|   35.05|
|OneInFiveBot           | 5.8| 2826326|48974364|   155| 17.26|  49.72|  3.00|   50.00|
|ThrowThriceBot         | 4.1| 1994569|48984367|    54| 21.70|  44.55|  2.53|   57.88|
|FutureBot              | 4.0| 1978660|48985814|    50| 17.93|  45.17|  2.36|   60.70|
|GamblersFallacy        | 1.3|  621945|48986528|    44| 22.52|  41.46|  2.82|   53.07|
|FlipCoinRollDice       | 0.7|  345385|48972339|    87| 15.29|  44.55|  1.61|   73.17|
|BlessRNG               | 0.2|   73506|48974185|    49| 14.54|  42.72|  1.42|   76.39|
|StopBot                | 0.0|    1353|48984828|    44| 10.92|  41.57|  1.00|   83.33|
|CooperativeSwarmBot    | 0.0|     991|48970284|    44| 10.13|  41.51|  1.36|   77.30|
|PointsAreForNerdsBot   | 0.0|       0|48986508|     0|  0.00|   0.00|  6.00|    0.00|
|SlowStart              | 0.0|       0|48973613|    35|  5.22|   0.00|  3.16|   47.39|
+-----------------------+----+--------+--------+------+------+-------+------+--------+

The following bots (except Rebel) are made to bend the rules, and the creators have agreed to not take part in the official tournament. However, I still think their ideas are creative, and they deserve a honorable mention. Rebel is also on this list because it uses a clever strategy to avoid sabotage, and actually performs better with the sabotaging bot in play.

The bots NeoBot and KwisatzHaderach does follow the rules, but uses a loophole by predicting the random generator. Since these bots take a lot of resources to simulate, I have added its stats from a simulation with fewer games. The bot HarkonnenBot achieves victory by disabling all other bots, which is strictly against the rules.

    Simulation or 300000 games between 52 bots completed in 66.2 seconds
    Each game lasted for an average of 4.82 rounds
    20709 games were tied between two or more bots
    0 games ran until the round limit, highest round was 31

    +-----------------------+----+--------+--------+------+------+-------+------+--------+
    |Bot                    |Win%|    Wins|  Played|   Max|   Avg|Avg win|Throws|Success%|
    +-----------------------+----+--------+--------+------+------+-------+------+--------+
    |KwisatzHaderach        |80.4|   36986|   46015|   214| 58.19|  64.89| 11.90|   42.09|
    |HarkonnenBot           |76.0|   35152|   46264|    44| 34.04|  41.34|  1.00|   83.20|
    |NeoBot                 |39.0|   17980|   46143|   214| 37.82|  59.55|  5.44|   50.21|
    |Rebel                  |26.8|   12410|   46306|    92| 20.82|  43.39|  3.80|   35.84|
    +-----------------------+----+--------+--------+------+------+-------+------+--------+

    +----------+-----+
    |Percentile|Score|
    +----------+-----+
    |     50.00|   45|
    |     75.00|   50|
    |     90.00|   59|
    |     95.00|   70|
    |     99.00|   97|
    |     99.90|  138|
    |     99.99|  214|
    +----------+-----+
\$\endgroup\$
22
  • 2
    \$\begingroup\$ So maybe the rules would be slightly clearer if they said "when a player ends their turn with a score of at least 40, everyone else gets a last turn". This avoids the apparent conflict by pointing out it's not reaching 40 that really triggers the last round, it's stopping with at least 40. \$\endgroup\$
    – aschepler
    Dec 19, 2018 at 22:15
  • 1
    \$\begingroup\$ @aschepler that's a good formulation, I'll edit the post when I'm on my computer \$\endgroup\$
    – maxb
    Dec 20, 2018 at 5:13
  • 2
    \$\begingroup\$ @maxb I've extended the controller to add more stats that were relevant to my development process: highest score reached, average score reached and average winning score gist.github.com/A-w-K/91446718a46f3e001c19533298b5756c \$\endgroup\$
    – AKroell
    Dec 20, 2018 at 12:49
  • 2
    \$\begingroup\$ This sounds very similar to a very fun dice game called Farkled en.wikipedia.org/wiki/Farkle \$\endgroup\$
    – Caleb Jay
    Dec 20, 2018 at 19:02
  • 5
    \$\begingroup\$ I'm voting to close this question because it's already de-facto closed to new answers ("The tournament is now over! The final simulation was run during the night, a total of 3∗108 games") \$\endgroup\$ Sep 4, 2019 at 11:43

49 Answers 49

1
2
1
\$\begingroup\$

FlipCoinRollDice

class FlipCoinRollDice(Bot):
    def make_throw(self, scores, last_round):
        while random.randint(1,2) == 2:
            throws = random.randint(1,6) != 6
            x = 0
            while x < throws:
                x = x + 1
                yield True
        yield False

This is a weird (untested) one. It flips a coin and if it's heads it rolls a dice and throws the amount the dice shows.

I can't test it now so if there are syntax errors, let me know :)

\$\endgroup\$
4
  • \$\begingroup\$ When I saw the name, I was afraid that it'd be a copy of the BlessRNG or BringMyOwn_dice bots, but this is some kind of middle ground in a way. I'm running a simulation now! Congratulations on your first KotH answer by the way! \$\endgroup\$
    – maxb
    Dec 21, 2018 at 14:37
  • \$\begingroup\$ Thank you :) I was hesitant to post it at first. It might not perform the best but it will be interesting to see. \$\endgroup\$ Dec 21, 2018 at 14:57
  • 1
    \$\begingroup\$ Don't hesitate, just look at PointsAreForNerdsBot, it's fun to participate even if you don't win. \$\endgroup\$
    – maxb
    Dec 21, 2018 at 15:02
  • \$\begingroup\$ Rather than a syntax error it's a logic error: throws = random.randint(1,6) != 6 evaluates to a boolean instead of the random number \$\endgroup\$
    – Belhenix
    Dec 21, 2018 at 16:53
1
\$\begingroup\$

LeadBy5Bot

class LeadBy5Bot(Bot):
    def make_throw(self, scores, last_round):
        while True:
            current_score = scores[self.index] + sum(self.current_throws)
            score_to_beat = max(scores) + 5
            if current_score >= score_to_beat:
                break
            yield True
        yield False

Always wants to be in the lead by 5.

Edit: New Bot

RollForLuckBot

class RollForLuckBot(Bot):

    def make_throw(self, scores, last_round):
        while sum(self.current_throws) < 21:
            yield True
        score_to_beat = max([x for i,x in enumerate(scores) if i!=self.index]) + 10
        score_to_beat = max(score_to_beat, 44)
        current_score = scores[self.index] + sum(self.current_throws)
        while (last_round or (current_score >= 40)):
            current_score = scores[self.index] + sum(self.current_throws)
            if current_score > score_to_beat:
                break
            yield True
        # roll more if we're feeling lucky    
        while (random.randint(1,6) == self.current_throws[-1]):
            yield True    
        yield False

A bot that borrows from EnsureLead, I prefer using 21 as it's the average of 6d6 (6x3.5), with 6 dice rolls leaving > 70% chance of the next roll being a 6. Also, we continue to roll if we roll a separate die and match our last throw after hitting 21.

\$\endgroup\$
4
  • \$\begingroup\$ Didn't notice AlphaBot till after making it. I'm curious how they'll do in a game together. \$\endgroup\$
    – willuwontu
    Dec 19, 2018 at 21:51
  • \$\begingroup\$ as a note, yield true should have upper T (python error) \$\endgroup\$
    – Belhenix
    Dec 20, 2018 at 0:09
  • \$\begingroup\$ @Belhenix Edited, guess I missed that when I was typing it out. \$\endgroup\$
    – willuwontu
    Dec 20, 2018 at 0:11
  • \$\begingroup\$ Woo, it made it into the top 50% (14 out of 28) \$\endgroup\$
    – willuwontu
    Dec 20, 2018 at 17:35
1
\$\begingroup\$

Stalker

This bot tries to be within 4 points from the leader by the last round. Otherwise gets moderate gains

class Stalker(Bot):

    def make_throw(self, scores, last_round):

        # on last round pray to rng gods to beat the highest score 
        while last_round and scores[self.index] + sum(self.current_throws) <= max(scores):
            yield True 

        if last_round and scores[self.index] + sum(self.current_throws) > max(scores):
            yield False 

        # on the earlier rounds try to aim a moderate gain
        if max(scores) < 26:
            while sum(self.current_throws) < 16:
                yield True
            yield False

        # throw until 1 dice throw behind the leader
        target = max(scores) - 5

        while scores[self.index] + sum(self.current_throws) <= target:
            yield True
        yield False

AgressiveStalker

This one goes aggressive if he is leading late towards the end game, otherwise stalks

class AggressiveStalker(Bot):

    def make_throw(self, scores, last_round):

        # on last round pray to rng gods to beat the highest score 
        while last_round and scores[self.index] + sum(self.current_throws) <= max(scores):
            yield True 

        if last_round and scores[self.index] + sum(self.current_throws) > max(scores):
            yield False 

        # on the earlier rounds try to aim a moderate gain
        if max(scores) < 26:
            while sum(self.current_throws) < 16:
                yield True
            yield False


        # if we are leading go for the win
        if max(scores) > 25 and max(scores) == scores[self.index]:
            while scores[self.index] + sum(self.current_throws) < 40:
                yield True
            yield False

        # if we are behind throw until 1 dice throw behind the leader
        target = max(scores) - 5

        while scores[self.index] + sum(self.current_throws) <= target:
            yield True
        yield False
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Very impressive securing the fifth place with AggressiveStalker! \$\endgroup\$
    – maxb
    Dec 22, 2018 at 11:07
1
\$\begingroup\$

BePrepared

class BePrepared(Bot):
    ODDS = [1.0, 0.8334, 0.8056, 0.7732, 0.7354, 0.6913, 0.6398, 0.6075, 0.5744, 0.5414, 0.509, 0.4786, 0.4519, 0.426, 0.4012, 0.3778, 0.3559, 0.3354, 0.316, 0.2977, 0.2805, 0.2643, 0.249, 0.2347, 0.221, 0.2083, 0.1962, 0.1848, 0.1742, 0.1641, 0.1546, 0.1457, 0.1372, 0.1293, 0.1218, 0.1147, 0.1081, 0.1018, 0.0959, 0.0904, 0.0851, 0.0802, 0.0755, 0.0712, 0.067, 0.0631, 0.0595, 0.0561, 0.0528, 0.0498, 0.0469, 0.0442, 0.0416, 0.0392, 0.0369, 0.0348, 0.0328, 0.0309, 0.0291, 0.0274, 0.0258, 0.0243, 0.0229, 0.0216, 0.0204, 0.0192, 0.0181, 0.017, 0.0161, 0.0151, 0.0142, 0.0134, 0.0126, 0.0119, 0.0112, 0.0106, 0.0099, 0.0094, 0.0088, 0.0083, 0.0078, 0.0074, 0.007, 0.0066, 0.0062, 0.0058, 0.0055, 0.0052, 0.0049, 0.0046, 0.0043]

    def odds_of_reaching(self, target):
        if target < 0:
                return 1
        elif target > 90:
                return 0
        else:
                return self.ODDS[target]

    def odds_of_winning_with(self, target, scores):
        odds = self.odds_of_reaching(target)
        for score in scores:
                odds *= 1 - (self.odds_of_reaching(target - score + 2) )
        return odds

    def make_throw(self, scores, last_round):
        if last_round:
                gone, to_go = [sum(self.current_throws)], []
                for score in scores[:self.index]+scores[self.index+1:]:
                        delta = score - scores[self.index]
                        if score < self.end_score:
                                to_go.append(delta)
                        else:
                                gone.append(delta)
                target = max(gone)
                odds = self.odds_of_winning_with(target, to_go)
                next_odds = self.odds_of_winning_with(target+1, to_go)
                while next_odds > odds:
                        target += 1
                        odds = next_odds
                        next_odds = self.odds_of_winning_with(target+1, to_go)        
        else:
                target = min(20, self.end_score - scores[self.index] - 3)
        while sum(self.current_throws) < target:
                yield True
        if last_round or sum(self.current_throws) + scores[self.index] < self.end_score:
                yield False
        else:
                for result in self.make_throw(scores, True):
                        yield result

Targets 20, then targets 37. Once it's the last round (either because it's accidentally gone over 40 or because another bot has), gets aggressive in proportion to how many other bots are still to go and have high scores.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ I have updated the highscores to include your bot. I'd say it fares quite well, being in the top third. \$\endgroup\$
    – maxb
    Dec 22, 2018 at 11:00
1
\$\begingroup\$

GoTo20orBestBot

I've been slow to submit an answer because I've tried to analyze this as a Markov chain, looking for insights.

Several submissions have been based on looking at the expected value of another roll, which is positive if an only if your current score is less than 15. But looking at the expected value takes too narrow a view of the problem, since what you really want to do is maximize the chance that you'll beat all the other bots. To win, you're going to have to be lucky, and so it's worth also thinking about the variance of your scores.

If you're unlucky, you won't win, so the only rolls that really matter are the ones where you are moderately lucky. Under those circumstances, it makes sense to be a bit more aggressive than just rolling to a positive expected value.

And, of course, if someone else is about to win, you've got nothing left to lose, so you ought to just go for it.

I tried various versions of this bot, going to everything from 16 to 25, and this one consistently out performed the others.

In spirit, this is very similar to @tsh GoTo20Bot, but instead of going only one point higher that the current lead, I first pass the leader, and if I have fewer than 20 points in the current round, I keep rolling.

class GoTo20orBestBot(Bot):

    def make_throw(self, scores, last_round):
        # If someone's about to win, roll until you've beat them or died.
        if max(scores)>40:
            while scores[self.index] + sum(self.current_throws) <= max(scores):
                yield True     
        # If you have not already, roll at least until the expected value of a
        # roll is negative
        while sum(self.current_throws) < 20:
            yield True
        yield False

GoToSeventeenRollTenBot

This turned out to be a surprisingly successful approach in my testing. It consistently out performed both a simple "GoTo20Bot" and "Roll10Bot". Not sure why.

class GoToSeventeenRollTenBot(Bot):

    def make_throw(self, scores, last_round):
        while sum(self.current_throws) < 17:
            yield True
        for i in range(10):
            yield True
        yield False
\$\endgroup\$
1
\$\begingroup\$

ClunkyChicken

class ClunkyChicken(Bot):
    def make_throw(self, scores, last_round):
        #Go for broke in last round
        if last_round:
            while scores[self.index] + sum(self.current_throws) <= max(scores):
                yield True
        #Not Endgame yet
        if scores[self.index] < (self.end_score+6):
            #Roll up to 4 more times, but stop just before forcing the last round
            for i in range(4):
                if scores[self.index] + sum(self.current_throws) < (self.end_score - 6):
                    yield True
                else:
                    break
            yield False
        #Roll 4 times - trying to force Last Round with "reasonable" score
        else:
            for i in range(4):
                yield True
        yield False

On the last round, this bot will roll until it beats the current high score - there's no reward for Second Place.

If it is within 6 of the end score (typically 40) then it will roll 3 4 times to try and set a decent target for other bots to roll 6s aiming at.

If it is without 6 of the end score, it will roll up to 3 4 times until it is within 6, and hold position there, ready for that last triple-roll burst.

24/12: Increased the rolls from 3 (42% chance of having rolled a 6) to 4 (51% chance of having rolled a six) - riskier, but I suspect my cut-off may be limiting the Bot's score.

3 rolls: Win% 19.1, Avg Score 21.17, Avg Win 45.40, Success% 45.07

WhereFourArtThouChicken

class WhereFourArtThouChicken(Bot):
    def make_throw(self, scores, last_round):
        for i in range(4):
            yield True
        yield False

Like the ClunkyChicken, this will attempt 4 rolls (plus the mandatory roll) - unlike the ClunkyChicken, it doesn't attempt to apply any logic as to whether it should stop or play things safe. (If this bot does better, I will be very disappointed xD)

\$\endgroup\$
1
\$\begingroup\$

BrainBot

class BrainBot(Bot):
    import numpy as np

    def  __init__(self, index, end_score):
        super().__init__(index, end_score)
        self.brain = [[[-0.1255, 0.338, 0.5265, -0.2728], [-0.2064, -1.9173, 0.1845, -0.2536], [-0.6737, -0.1334, -0.7055, 0.0797], [-0.6055, -0.0126, 0.9261, -0.603], [0.447, -0.5381, -1.7416, 0.0596], [0.1649, -0.6795, -1.1039, -0.0138], [-0.2782, -0.2005, -1.2967, -0.8073], [0.2329, -0.5591, 1.6192, -0.218]], [[0.7411, 0.3139, 0.435, 1.002, -0.3148, -0.7791, -0.6532, -0.4672, -0.4655], [0.1982, 0.3713, 0.0426, -0.9227, 1.6118, 0.9431, 0.5612, 0.1208, 0.1115]]]

    def decide(self, input_data):
        x = np.array(input_data)
        wI = 0
        for w in self.brain:
            x = [1.0 / (1 + np.exp(-el)) for el in np.dot(w, x)]
            if wI<len(self.brain)-1:
                x.append(-1)
        return np.argmax(x)

    def make_throw(self, scores, last_round):
        while True:
            oppMaxInd = -1
            oppMaxScore = 0
            for i in range(len(scores)):
                if i==self.index: continue
                if scores[i] > oppMaxScore:
                    oppMaxScore = scores[i]
                    oppMaxInd = i
            if last_round:
                yield scores[self.index]+sum(self.current_throws)<oppMaxScore+1
            else:
                s = [oppMaxScore/self.end_score,
                     scores[self.index]/self.end_score,
                     sum(self.current_throws)/self.end_score,
                     1.0 if last_round else 0.0]
                yield self.decide(s)==1

This bot has a "brain" that is given the input [highest opponent score, own score, round score, is it final round] which it multiplies by a series of matrices to obtain the resulting decision vector. Also, I added some logic for the endgame, since it seems my algorithm couldn't take that into account (although the bit about "is it last round" is given in the input).

I used an evolutionary algorithm to try to find good coefficients for the matrices. It didn't work perfectly but the bot seems to do better than a random one. I'd be very interested to see if this idea can be improved. (How to do this for example with some machine learning techniques. How could we generate training data about choices when to make throw and when not?)

\$\endgroup\$
3
  • \$\begingroup\$ Welcome to PPCG! I'd say that this problem is suitable for a machine learning or neural net approach, though I don't have a lot of experience within the area. I guess that you could set up unsupervised learning through simulating a ton of games and looking at how the bot's decision affects its score \$\endgroup\$
    – maxb
    Dec 23, 2018 at 23:28
  • \$\begingroup\$ I looked through your bot again, and it seems like it was misbehaving. Due to the issue with the indentation, I think the methods were not registered as class methods in previous simulation. As such, your bot behaved just like the original Bot class, which put it at the bottom. With that issue fixed, your bot is behaving a lot better! I'm running a larger simulation right now, and your winrate is almost 20%. \$\endgroup\$
    – maxb
    Dec 27, 2018 at 17:27
  • \$\begingroup\$ @maxb Thanks! I was wondering why the outcome was so low :D \$\endgroup\$
    – ploosu2
    Dec 27, 2018 at 17:55
1
\$\begingroup\$

Gladiolen

class Gladiolen(Bot):
    numThrows = 6

    def make_throw(self, scores, last_round):
        i = self.index

        if last_round:
            others = scores[:i] + scores[i+1:]
            target = max(others) - scores[i]
            while sum(self.current_throws) <= target:
                yield True
            yield False
        else:
            target = 33 - scores[i]
            for _ in range(self.numThrows):
                if sum(self.current_throws) >= target:
                    yield False
                yield True
            yield False

Gladiolen starts off boldly, throwing seven times in a row. But when it comes close to 40, it'll slow down, hoping for somebody else to hit 40 first. When the last_round kicks in, it is "der Tod oder die Gladiolen" again. If you don't know what that means, you should google it:)

\$\endgroup\$
1
  • \$\begingroup\$ Even if it isn't your first post, welcome to PPCG! Your bot seems to be behaving quite well, with a win rate of around 20%. I'll run a proper simulation during the night and update the scoreboard tomorrow morning. \$\endgroup\$
    – maxb
    Dec 30, 2018 at 21:16
1
\$\begingroup\$

The old WisdomOfCrowds is gone. The old commentary is preserved here:

This bot tries to take advantage of the skills of the other bots! In each round, it asks the current top three scorers what they would do in its situation, then goes with the majority verdict. Somewhat disappointingly, it seems to score only around the average of all other bots -- maybe the leaders' strategies aren't transferable, or maybe the fact that many bots change their plans in the last round confuses the naive majority-verdict idea.

and of course the actual code can be retrieved from the edit history.

This bot replaces it entirely, implementing a similar idea rather more cleanly (it doesn't alter the state of any of the other bots, even incidentally). Also, it doesn't bother with the majority verdict any more (too expensive), but just interrogates (a clone of) the current leader.

import copy
import operator
class TleilaxuBot(Bot):
    """
    On each roll, identify the leading bot, make a ghola from
    it, and interrogate the ghola about whether to roll again.
    """
    def __init__(self, *args):
        super().__init__(*args)
        self.bots = None
        self.allies = {"Tleilaxu", "WisdomOfCrowds"}

    def find_bots(self):
        for f_info in inspect.stack():
            try:
                self.bots = f_info.frame.f_locals["game_bots"]
                break
            except KeyError:
                pass
            finally:
                del f_info

    def face_dancer(self, bot):
        return any([a in bot.__class__.__name__ for a in self.allies])

    def find_leader(self, scores):
        # Exclude self and allies to avoid deadly embrace!
        z = [(s, b) for s, b in zip(scores, self.bots) if not self.face_dancer(b)]
        return sorted(z, key=operator.itemgetter(0))[-1][1]

    def axolotl(self, bot):
        """
        First create a new bot that's just an empty shell, then
        duplicate the attributes of the original into the ghola.
        """
        ghola = object.__new__(bot.__class__)
        ghola.__dict__ = bot.__dict__.copy()
        for k in bot.__dict__ :
            a = getattr(bot, k)
            # Some attributes can't be (but don't need to be) deepcopied.
            try:
                setattr(ghola, k, copy.deepcopy(a))
            except:
                try:
                    setattr(ghola, k, copy.copy(a))
                except:
                    setattr(ghola, k, a)

        ghola.index = self.index   # Change the ghola's allegiance
        return ghola

    def interrogate(self, ghola, scores, last_round):
        try:
            ghola.update_state(self.current_throws[:])
            for answer in ghola.make_throw(scores, last_round):
                break
        except:
            answer = True # or False?
        return answer

    def make_throw(self, scores, last_round):
        if not self.bots:
            self.find_bots()
        tmp_scores, ghola = scores[:], self.axolotl(self.find_leader(scores))
        while True:
            yield self.interrogate(ghola, tmp_scores, last_round)

It seems to score higher than WisdomOfCrowds but of course this depends on which other bots it's competing against - it's only ever as good as the best of the rest!

\$\endgroup\$
18
  • \$\begingroup\$ Welcome to PPCG (again)! I like the style of this answer, and it is a unique tactic indeed. However, it is not allowed as it is currently presented. This is due to the rule "Any attempt to tinker with the controller, runtime or other submissions will be disqualified. All submissions should only work with the inputs and storage they are given." This rule is broken because you access the game_bots array, meaning that you call methods in instantiated bot objects that are part of the game. To circumvent this, you could create your own instance of each bot class, and use your local copy to ask... \$\endgroup\$
    – maxb
    Dec 31, 2018 at 15:10
  • \$\begingroup\$ ... any question you want. The key here is that you can use other classes and call them (though you may definitely not modify them), but you may not use other objects that are part of the competition. As long as you notice that distinction, your tactic is appropriate. I noticed when I tested your bot that it made OptFor2X fail during the simulations. I don't know exactly what caused it, but removing your bot removed the error in OptFor2X. If you need any help with implementation, send me a message in the chat room, though I might not be available until tomorrow or the day after. \$\endgroup\$
    – maxb
    Dec 31, 2018 at 15:15
  • \$\begingroup\$ OptFor2X is not prepared for the situation of having a score between 1 and 13. That's usually fine, because it won't put itself into this situation! You'd need except TypeError: or just except: to catch this problem. I think most bots (including OptFor2X) will not be harmed by this bot, but it may spoil the internal state of bots like SlowStartand StepBot. \$\endgroup\$ Dec 31, 2018 at 16:18
  • \$\begingroup\$ I see no reason for the yield False statement and expect it to harm the success of your bot. \$\endgroup\$ Dec 31, 2018 at 17:51
  • \$\begingroup\$ @ChristianSievers: the yield False was an attempt to avoid breaking other (later) bots by calling them before they had first been called by the game itself. Also it may not really be meaningful to ask about the leaders during the first round, when some bots have not yet had a turn. So WisdonOfCrowds just accepts a single roll on its first turn; the majority-vote logic only kicks in on subsequent turns. \$\endgroup\$
    – DaniO
    Dec 31, 2018 at 19:19
1
\$\begingroup\$

StepBot

At first I wanted to do a 4 throws bot with a gain limit of 15 but as I made it I just went wild with the coding.

Now that I notice it, it's quite bold Not so bold anymore but still a bit bold.

StepBot now enters the top ranks! Didn't think he'd made it.

Competition got really tough but I'm satisfied with the bot's results.

class StepBot(Bot):
    def __init__(self, *args):
        super().__init__(*args)
        self.cycles = 0
        self.steps = 8
        self.smallTarget = 15
        self.bigTarget = 20
        self.rush = True
        #target for game
        self.breakPoint = 40

    def make_throw(self, scores, last_round):
        # Stacks upon stacks upon stacks
        self.bigTarget += 1
        self.cycles += 1
        self.steps += 1
        if self.cycles <=3:
            self.smallTarget += 1
        else:
            self.bigTarget -= 1 if self.steps % 2 == 0 else 0

        target = self.bigTarget if scores[self.index] < 12 else self.bigTarget if self.cycles <=3 else self.smallTarget
        # If you didn't start the last round (and can't reach normally), panic ensues
        if last_round and max(scores) - (target // 3) > scores[self.index]:
            # Reaching target won't help, run for it!
            while max(scores) > scores[self.index] + sum(self.current_throws):
                yield True
        else:
            if last_round:
                self.breakPoint = max(scores)
            # Hope for big points when low, don't bite more than you can chew when high
            currentStep = 1
            while currentStep <= self.steps:
                currentStep += 1
                if sum(self.current_throws) > target:
                    break;
                yield True
                # After throw, if we get to 40 then rush (worst case we'll get drawn back)
                if scores[self.index] + sum(self.current_throws) > self.breakPoint and self.rush:
                    currentStep = 1
                    self.steps = 2
                    self.rush = False
                    target = 8 + ((random.randint(7, 15) ** 0.5) // 1)
                    # print(target)
            # If goal wasn't reached or surpassed even after rushing, run for it!
            while last_round and max(scores) > scores[self.index] + sum(self.current_throws):
                yield True
        yield False
\$\endgroup\$
2
  • \$\begingroup\$ Thanks a lot for the heads-up \$\endgroup\$
    – Belhenix
    Dec 19, 2018 at 18:03
  • 1
    \$\begingroup\$ I was waiting for your answer! Thanks for all the help in the sandbox. Someone still found an issue with the controller posted there, with bots casting the die one more time than they wanted. This has been fixed in the controller in the post. \$\endgroup\$
    – maxb
    Dec 19, 2018 at 19:56
1
\$\begingroup\$

SafetyNetBot

class SafetyNet(Bot):
    def __init__(self, *args):
        self.previous_scores = []
        self.current_scores = []
        self.difference = []
        super().__init__(*args)

    def make_throw(self, scores, last_round):
        self.current_scores = [x for i, x in enumerate(scores)]
        
        if len(self.current_scores) > len(self.previous_scores):
            self.previous_scores = self.previous_scores + ([0] * (len(self.current_scores) - len(self.previous_scores)))
        
            
        self.difference = list(map(lambda x,y: x-y, self.current_scores, self.previous_scores))
        self.difference = [x for i, x in enumerate(self.difference) if x>0]
        average_throws = int((float(sum(self.difference))/float(max(1,len(self.difference))))/3.5)
        current_score = scores[self.index] + sum(self.current_throws)
        high_score = max([x for i,x in enumerate(scores) if i!=self.index])
        self.previous_scores = [x for i, x in enumerate(scores)]

        for x in range(1,average_throws-1): #we already threw once getting here
            yield True
        
        current_score = scores[self.index] + sum(self.current_throws)
        if last_round:
            while current_score < high_score:
                yield True
                current_score = scores[self.index] + sum(self.current_throws)
        
        yield False

This bot figures out the average number of rolls the bots are safely making and rolls that many times. This will be thrown off by bots that roll too much hit 40 and then fail, as well as having too few bots that safely make rolls.

\$\endgroup\$
16
  • \$\begingroup\$ seems interesting but scores is not defined in __init__ \$\endgroup\$
    – Belhenix
    Jan 4, 2019 at 7:30
  • \$\begingroup\$ You could set both arrays to be empty or None in the init function, and then set the update in the beginning of make_throw only. The concept is good, but it needs some fixing before being able to participate. The tournament ends later today, but if you fix it I'll include you in the official tournament \$\endgroup\$
    – maxb
    Jan 4, 2019 at 12:54
  • \$\begingroup\$ @maxb I think I fixed it now. Let me know if it has any other issues though. \$\endgroup\$
    – willuwontu
    Jan 4, 2019 at 16:13
  • \$\begingroup\$ @williamporter You only update previous_scores if you reach that line, i.e. if you don't throw a 6. Not sure if that's what you intended. \$\endgroup\$ Jan 4, 2019 at 16:35
  • \$\begingroup\$ @ChristianSievers it is, since I can't think of a way to get them otherwise. Looking over your bot, is update_state called before our first roll every time? \$\endgroup\$
    – willuwontu
    Jan 4, 2019 at 16:36
1
\$\begingroup\$

Ro

class Ro(Bot):

    def make_throw(self, scores, last_round):
        current_score = scores[self.index]
        bonus_score = sum(self.current_throws)
        total_score = current_score + bonus_score
        score_to_beat = max([x for i,x in enumerate(scores) if i!=self.index]) + 5
        initiate_end = False

        if current_score < 33:
            target_score = 33 - current_score

        if current_score < 17:
            target_score = 17
        
        if current_score >= 33:
            target_score = max(45, score_to_beat) - current_score
            initiate_end = True
        
        if last_round:
            target_score = score_to_beat - current_score
        
        while bonus_score < target_score:
            yield True
            bonus_score = sum(self.current_throws)
            total_score = current_score + bonus_score
        
        #if we go too far on accident/luck
        if total_score >= 40 and (not initiate_end):
            yield True
            
        yield False

Small simple bot, we strive to hit 45 (2 points above the average win score) in 3 steps, with the last step being the smallest to minimize our risk.

\$\endgroup\$
2
  • \$\begingroup\$ When this bot's score is zero, both the first and the second if condition are satisfied, and target_score is set to 33. Therefore, this bot actually follows a two step strategy. \$\endgroup\$ Jan 9, 2019 at 22:38
  • \$\begingroup\$ Oh snap, I had my if statement backwards @ChristianSievers Thanks for pointing it out. \$\endgroup\$
    – willuwontu
    Jan 10, 2019 at 1:30
1
\$\begingroup\$
class HarkonnenBot(Bot):
    """
    House Harkonnen is unrivalled in treachery and double-dealing.
    This bot adminsters an elacca drug to all its rivals, removing
    their instinct for self-preservation and compelling them to
    obsessively roll again and again, until they *die* ⚅
    """
    def __init__(self, *args):
        super().__init__(*args)
        self.bots = None
        for f_info in inspect.stack():
            try:
                self.bots = f_info.frame.f_locals["game_bots"]
                break
            except KeyError:
                pass
            finally:
                del f_info

    def update_state(self, current_throws):                                                                                                                               
        # Do not count what you have lost. Count only what you still have.
        pass                                                                                                                                                              

    def elacca(self, scores, last_round):
        while True:
            yield True

    def chaumurky(self):
        my = self.__class__                                                                                                                                               
        for bot in self.bots:
            # Administer elacca, and defeat Rebel's antidote to it ;-)
            roll = my.make_throw if bot == self else my.elacca
            bot.make_throw = functools.partial(roll, bot)
            bot.update_state = functools.partial(my.update_state, bot)
        # Destroy the evidence
        self.bots = None

    def make_throw(self, scores, last_round):
        if self.bots:
            self.chaumurky()
        yield False

Obviously, this bot has to Ignore All The Rules, except the one that says "Sabotage is allowed, and encouraged". But when did a Harkonnen ever need encouragement? Or even permission? };D

\$\endgroup\$
3
  • \$\begingroup\$ My hippie soul likes how this turns every bot into a cooperating bot (in the sense of the cooperative swarm). If only this bot cooperated, too... \$\endgroup\$ Jan 2, 2019 at 22:52
  • \$\begingroup\$ Harkonnens, cooperate? Cooperation is for children and slaves! \$\endgroup\$
    – DaniO
    Jan 3, 2019 at 15:08
  • \$\begingroup\$ Added final (post-tournament) update; now circumvents Rebel's defences and get a win rate over 95% ;-) \$\endgroup\$
    – DaniO
    Jan 22, 2019 at 22:42
1
\$\begingroup\$

This is a derivative of TleilaxuBot and CopyBot; but instead of querying the leader of the current game, it clones and questions the bot with the best overall win rate so far.

import itertools
import operator
class CloneBot(Bot):
    """
    At the start of each game, identify the bot class that has
    the best win rate. If we already have a clone of it, use
    that; otherwise make one and cache it for future use. Then
    on each throw, ask the current clone whether to roll again.
    """
    _controller = None

    def _setup(self):
        # One-time initialisation (per-class-instance/per-thread)
        my = self.__class__
        my._super = my.mro()[1]
        my._controller = self._find_controller()
        my._exclude = (
                        my.__name__, "Clone", "Committee",
                        "CopyBot", "Crowd", "Ghola", "Tleilaxu",
                      )
        my._farm = {}
        my._index = { c.__name__: c for c in self._controller.bots }
        my._index[self._super.__name__] = self._super
        my._played = self._controller.played_games
        my._won = self._controller.wins

    def _find_controller(self):
        for f_info in inspect.stack():
            try:
                other = f_info.frame.f_locals["self"]
                if other.__class__.__name__ == "Controller":
                    return other
            except KeyError:
                pass
            finally:
                del f_info

    def __init__(self, *args):
        super().__init__(*args)
        if not self._controller:
            self._setup()
        self.real = self.index >= 0
        self.find_clone()

    def find_clone(self):
        # Find or create a clone of the bot with the best win rate
        leader = self.find_leading_class()
        if leader in self._farm:
            self.clone = self._farm[leader]
            self.clone.index = self.index
        else:
            self.clone = self._index[leader](self.index, self.end_score)
            self._farm[leader] = self.clone

    def find_leading_class(self):
        # Return the name of the bot class with the best win rate.
        # Exclude self and similar bots to avoid deadly embrace!
        maxrate = 0.0
        rates = [(maxrate, self._super.__name__)]
        if self.real:
            for botname, won in self._won.items():
                if not any([a in botname for a in self._exclude]):
                    winrate = won/max(1, self._played[botname])
                    if winrate >= maxrate:
                        maxrate = winrate
                        rates.append((winrate, botname))
        rates = itertools.filterfalse(lambda r: r[0] < maxrate, rates)
        return sorted(rates, key=operator.itemgetter(0))[-1][-1]

    def update_state(self, curr_throws):
        super().update_state(curr_throws)
        self.clone.update_state(curr_throws)

    def make_throw(self, scores, last_round):
        try:
            for ans in self.clone.make_throw(scores, last_round):
                yield ans
        except Exception:
            yield False

The perhaps-surprising thing is that this actually beats every other bot in the main competition - even OptFor2X! Not by very much, but over the sufficiently long term (runs of more than ~1 million simulations) it usually beats OptFor2X by about 0.2%. Which seems odd, because it's mostly just getting its decisions from its own clone of OptFor2X! Here's an example run:

$ nice -20 python3 ./forty_game_controller.py -t 4 -n 50000000 -b 8 -A  

Starting simulation with 52 bots  
Simulating 50000000 games with 8 bots per game  
Running simulation on 4 threads  

[==================================================] 100%  
[==================================================] 100%  
[==================================================] 100%  
[==================================================] 100%  

Simulation or 50000000 games between 52 bots completed in 18358.5 seconds  
Each game lasted for an average of 3.80 rounds  
4972153 games were tied between two or more bots (9.94%)  
0 games ran until the round limit, highest round was 18  

+-----------------------+----+--------+--------+------+------+-------+------+--------+  
|Bot                    |Win%|    Wins|  Played|   Max|   Avg|Avg win|Throws|Success%|  
+-----------------------+----+--------+--------+------+------+-------+------+--------+  
|CloneBot               |22.4| 1724957| 7696253|   101| 20.82|  44.32|  4.00|   33.31|  
|OptFor2X               |22.2| 1705414| 7696855|    98| 20.80|  44.36|  4.00|   33.35|  
|StepBot                |21.0| 1614765| 7690916|    91| 20.75|  43.40|  4.55|   24.17|  
|Rebel                  |20.9| 1610273| 7691858|    96| 21.69|  44.22|  3.88|   35.29|  
|EnsureLead             |20.8| 1602774| 7690527|    89| 20.75|  44.07|  4.49|   25.14|  
|Hesitate               |20.8| 1596899| 7691164|    99| 21.78|  44.17|  3.88|   35.41|  
|Roll6Timesv2           |20.6| 1585827| 7697765|    98| 21.18|  43.47|  4.36|   27.29|  
|BinaryBot              |20.5| 1579192| 7688993|    97| 21.35|  44.43|  3.82|   36.25|  
|AggressiveStalker      |20.4| 1570588| 7694504|    98| 20.81|  44.78|  3.88|   35.38|  
|QuotaBot               |20.4| 1568508| 7692304|    91| 20.22|  44.93|  4.50|   25.07|  
|FooBot                 |20.4| 1566110| 7692261|   113| 22.37|  43.71|  3.90|   34.96|  
|AdaptiveRoller         |20.3| 1559090| 7694793|   107| 21.03|  43.20|  4.51|   24.91|  
|BePrepared             |20.2| 1555711| 7696658|   101| 18.88|  47.67|  4.30|   28.40|  
|GoTo20Bot              |20.2| 1552725| 7695252|    90| 21.47|  43.17|  4.44|   26.07|  
|GoTo20orBestBot        |19.9| 1532451| 7689005|    95| 21.29|  44.02|  4.45|   25.80|  
|LastRound              |19.9| 1532052| 7691686|    96| 20.83|  43.42|  4.18|   30.28|  
|BrainBot               |19.9| 1527461| 7687387|    92| 19.55|  45.54|  4.45|   25.84|  
|Gladiolen              |19.8| 1526774| 7694652|    98| 20.48|  45.26|  3.89|   35.19|  
|Stalker                |19.8| 1522105| 7691552|    97| 20.56|  45.28|  3.78|   37.06|  
|ClunkyChicken          |19.4| 1494374| 7692268|    95| 21.48|  45.40|  3.55|   40.85|  
|FortyTeen              |19.2| 1479323| 7687913|    93| 21.24|  46.73|  3.87|   35.48|  
|Chaser                 |19.0| 1463357| 7689362|    91| 19.88|  45.58|  4.04|   32.69|  
|Crush                  |19.0| 1462214| 7688887|    98| 14.98|  43.02|  5.16|   14.05|  
|Ro                     |17.4| 1340309| 7689224|   106| 19.64|  49.23|  4.09|   31.77|  
|MatchLeaderBot         |16.9| 1301503| 7692455|   105| 18.96|  45.00|  3.17|   47.24|  
|RollForLuckBot         |16.6| 1280087| 7691043|   100| 17.60|  50.45|  4.72|   21.38|  
|TakeFive               |16.5| 1272700| 7696365|    95| 19.72|  44.61|  3.35|   44.19|  
|CopyBot                |15.7| 1204247| 7691305|   103| 18.89|  45.44|  3.80|   36.69|  
|Alpha                  |15.7| 1204720| 7695029|    96| 17.79|  46.63|  4.02|   32.93|  
|GoHomeBot              |15.6| 1200452| 7693064|    44| 13.39|  41.41|  5.49|    8.51|  
|LeadBy5Bot             |15.4| 1185736| 7692771|    97| 17.52|  46.85|  4.11|   31.56|  
|NotTooFarBehindBot     |15.4| 1183868| 7694948|    92| 18.12|  45.02|  2.97|   50.50|  
|GoToSeventeenRollTenBot|14.3| 1102743| 7689943|   101| 10.41|  49.24|  5.68|    5.42|  
|LizduadacBot           |14.2| 1094300| 7694015|    84|  9.81|  51.35|  5.72|    4.68|  
|BringMyOwn_dice        |12.6|  971094| 7693980|    44| 21.59|  41.47|  4.24|   29.36|  
|SafetyNet              |11.4|  880288| 7692264|    91| 15.83|  44.99|  2.35|   60.76|  
|WhereFourArtThouChicken|10.9|  837058| 7693152|    64| 22.65|  47.39|  3.59|   40.19|  
|ExpectationsBot        | 9.6|  734632| 7691870|    44| 24.72|  41.54|  3.57|   40.45|  
|OneStepAheadBot        | 8.8|  675539| 7690564|    50| 18.51|  46.01|  3.20|   46.61|  
|GoBigEarly             | 6.9|  531024| 7690540|    49| 21.06|  42.94|  3.89|   35.22|  
|OneInFiveBot           | 6.0|  463188| 7689148|   150| 17.49|  49.69|  3.00|   50.00|  
|ThrowThriceBot         | 4.3|  330265| 7693149|    54| 22.00|  44.54|  2.53|   57.87|  
|FutureBot              | 4.3|  328789| 7688116|    50| 18.21|  45.15|  2.36|   60.71|  
|GamblersFallacy        | 1.4|  109916| 7691758|    44| 22.84|  41.46|  2.80|   53.30|  
|FlipCoinRollDice       | 0.8|   59338| 7695670|    83| 15.56|  44.54|  1.61|   73.19|  
|CooperativeThrowTwice  | 0.6|   48174| 7693657|    49| 17.05|  43.15|  2.14|   64.30|  
|BlessRNG               | 0.2|   13258| 7693434|    49| 14.80|  42.71|  1.42|   76.40|  
|StopBot                | 0.0|     212| 7694242|    44| 11.12|  41.70|  1.00|   83.34|  
|CooperativeSwarmBot    | 0.0|     196| 7694437|    44| 10.29|  41.45|  1.37|   77.18|  
|CooperativeSwarm_1234  | 0.0|     158| 7687573|    44| 10.30|  41.53|  1.37|   77.19|  
|SlowStart              | 0.0|       0| 7692021|    31|  5.29|   0.00|  3.16|   47.30|  
|PointsAreForNerdsBot   | 0.0|       0| 7691448|     0|  0.00|   0.00|  6.00|    0.00|  
+-----------------------+----+--------+--------+------+------+-------+------+--------+  

+----------+-----+  
|Percentile|Score|  
+----------+-----+  
|     50.00|   44|  
|     75.00|   47|  
|     90.00|   51|  
|     95.00|   53|  
|     99.00|   58|  
|     99.90|   67|  
|     99.99|  150|  
+----------+-----+  

+-----------------------+-------+-----+  
|Bot                    |   Time|Time%|  
+-----------------------+-------+-----+  
|BrainBot               |7404.15| 15.3|  
|CopyBot                |2511.08|  5.2|  
|CloneBot               |1225.01|  2.5|  
|BePrepared             |1092.93|  2.3|  
|SafetyNet              |1072.67|  2.2|  
|OptFor2X               |1042.43|  2.2|  
|PointsAreForNerdsBot   | 966.03|  2.0|  
|LizduadacBot           | 958.76|  2.0|  
|Rebel                  | 943.04|  1.9|  
|Crush                  | 897.89|  1.9|  
|GoToSeventeenRollTenBot| 897.28|  1.9|  
|BringMyOwn_dice        | 892.27|  1.8|  
|StepBot                | 889.61|  1.8|  
|Ro                     | 881.75|  1.8|  
|EnsureLead             | 873.70|  1.8|  
|QuotaBot               | 869.99|  1.8|  
|RollForLuckBot         | 861.64|  1.8|  
|GoHomeBot              | 859.09|  1.8|  
|OneStepAheadBot        | 842.69|  1.7|  
|LeadBy5Bot             | 814.80|  1.7|  
|Chaser                 | 810.62|  1.7|  
|FutureBot              | 809.73|  1.7|  
|Alpha                  | 792.04|  1.6|  
|NotTooFarBehindBot     | 791.59|  1.6|  
|LastRound              | 788.76|  1.6|  
|Roll6Timesv2           | 767.75|  1.6|  
|GoTo20Bot              | 765.86|  1.6|  
|OneInFiveBot           | 764.31|  1.6|  
|GoTo20orBestBot        | 763.44|  1.6|  
|BinaryBot              | 763.02|  1.6|  
|AdaptiveRoller         | 762.56|  1.6|  
|AggressiveStalker      | 757.35|  1.6|  
|Gladiolen              | 753.05|  1.6|  
|Stalker                | 752.02|  1.6|  
|ExpectationsBot        | 742.51|  1.5|  
|ClunkyChicken          | 741.84|  1.5|  
|FooBot                 | 741.44|  1.5|  
|FortyTeen              | 725.43|  1.5|  
|MatchLeaderBot         | 722.53|  1.5|  
|Hesitate               | 710.54|  1.5|  
|GoBigEarly             | 708.21|  1.5|  
|TakeFive               | 665.17|  1.4|  
|GamblersFallacy        | 645.57|  1.3|  
|SlowStart              | 641.37|  1.3|  
|WhereFourArtThouChicken| 636.17|  1.3|  
|FlipCoinRollDice       | 585.46|  1.2|  
|CooperativeThrowTwice  | 507.59|  1.0|  
|ThrowThriceBot         | 496.03|  1.0|  
|BlessRNG               | 435.49|  0.9|  
|CooperativeSwarm_1234  | 398.50|  0.8|  
|CooperativeSwarmBot    | 397.98|  0.8|  
|StopBot                | 299.57|  0.6|  
+-----------------------+-------+-----+  
\$\endgroup\$
1
  • \$\begingroup\$ This is very impressive! Going for the strategy of the overall winner sounds a lot more stable than copying the leader of the current round. However, the fact that it beats every other competitor while relying on their strategies is remarkable. This is definitely one of my favorite bots. While it could be argued that it will copy the same bot 99.99% of the time, which would almost make it a copy of that bot, the idea is still unique and performant at the same time. Good job! \$\endgroup\$
    – maxb
    Feb 12, 2019 at 12:01
0
\$\begingroup\$

GamblersFallacy

class GamblersFallacy(Bot):
    def make_throw(self, scores, last_round):
        # since we're guaranteed to throw once, only throw up to 4 extra times
        for i in range(4):
            # the closer the score gets to winning,
            # and the closer the throws get to equaling 5,
            # the more likely the bot is to quit
            if (scores[self.index]/40.0 + len(self.current_throws)/5.0) < 0.90:
                yield True
            else:
                break
        yield False

This Bot believes the following to be true:

  1. I'm less likely to get a 6 if I roll fewer than 6 times.
  2. I'm more likely to get a 6 the closer I am to winning.
  3. I'm more likely to get a 6 the closer I am to my limit of 6 throws.

It acts accordingly, only rolling extra when it thinks it is safe to do so.

\$\endgroup\$
0
\$\begingroup\$

MatchLeaderBot

class MatchLeaderBot(Bot):
    # Try to match the current leader, then pass them in the last round
    def make_throw(self, scores, last_round):

        while True:
            current_top = max(scores)
            my_total = scores[self.index]
            my_round_total = sum(self.current_throws)
            my_current_total = my_total + my_round_total
            difference = current_top - my_current_total

            if last_round and my_current_total < current_top:
                # Go for gold while we still can
                yield True
                continue
            elif difference > 5:
                # Don't risk another throw if we could pass leader in one toss
                yield True
                continue
            break

        yield False

This bot attempts to get as close as possible to the current leader's score, then tries to pass them in the last round. It won't take the risk if one more toss could put it in the lead - unless it's the last round.

Still a work in progress as I'm a bit of a Python noob.

\$\endgroup\$
0
\$\begingroup\$

Crush

class Crush(Bot):
    def make_throw(self, scores, last_round):
        # Go for the win on the last round.
        if last_round:
            while scores[self.index] + sum(self.current_throws) <= max(scores):
                yield True
            yield False 

        # If no one is close enough, claim victory. 
        if max(scores[:self.index] + scores[self.index + 1:]) < self.end_score - 10:
            while scores[self.index] + sum(self.current_throws) < self.end_score:
                yield True
            yield False 

        # Otherwise, play safe and wait for someone else to cross the finish line.
        if scores[self.index] <= 20:
            while scores[self.index] + sum(self.current_throws) < 20:
                yield True
            yield False
        if scores[self.index] <= 35:
            while scores[self.index] + sum(self.current_throws) < 35:
                yield True
            yield False
        while True:
            yield True

Only go for the win if no one else is close enough to stop us. There's certainly a lot of improvement to be had here, so I'll probably optimize it further, but it does pretty well as is.

\$\endgroup\$
0
\$\begingroup\$

OptFor2X is causing a problem; this bit of code:

def make_throw(self,scores,last_round):
    myscore=scores[self.index]
    if last_round:
        target=max(scores)-myscore
        opscores = []
        scores += scores

changes the controller's game_scores[] array so it doesn't any more match the game_bots[] array.

To prevent accidental (or deliberate) damage of this type, the controller should probably pass a copy of the game_scores to each bot, as it does with the current_throws[] array:

bot.update_state(current_throws[:])                                       
for throw in bot.make_throw(game_scores[:], last_round): 

Or for greater efficiency, make the copy outside the for-loop:

bot.update_state(current_throws[:])
tmp_scores = game_scores[:]                                       
for throw in bot.make_throw(tmp_scores, last_round): 

Enjoy,
:D

\$\endgroup\$
6
  • 1
    \$\begingroup\$ Welcome to the site. I know you can't comment but this type of thing should be left as a comment because it is not an answer. \$\endgroup\$
    – Wheat Wizard
    Dec 30, 2018 at 13:11
  • \$\begingroup\$ Well spotted! I agree that the controller should not allow this, but changed my bot anyway. It doesn't seem to me that your two versions would really perform differently. \$\endgroup\$ Dec 30, 2018 at 15:10
  • \$\begingroup\$ Welcome to PPCG! You're very right in that each bot should only receive a copy of the state instead of direct references. I'll look into that. For the future, an issue like this is best suited for the chat room related to this challenge, you can ping the author of the challenge (me) if they're slow to answer. I don't think the modification was deliberate, but it should not be possible to do that. \$\endgroup\$
    – maxb
    Dec 30, 2018 at 19:38
  • \$\begingroup\$ Also for the sake of efficiency, the two versions perform equally, since the make_throw function is only called once, as an iterator. Though I think that the second version is more readable, but this site is not great on readable code in general... \$\endgroup\$
    – maxb
    Dec 30, 2018 at 21:18
  • \$\begingroup\$ @Wît Wisarhd: yes, I tried to submit it as a comment about OptFor2X, but as a new contributor I'm not allowed to comment on other people's posts! \$\endgroup\$
    – DaniO
    Dec 30, 2018 at 23:16
0
\$\begingroup\$

CopyBot

This bot is basically an updated version of TleilaxuBot. It keeps its own copies of all bots, and asks the current leader for advice. If the leader is unable to answer, it defaults to throwing the die 5 times. It achieves a win rate of about 16% using this strategy.

class CopyBot(Bot):
    _bot_copies = {}
    _avoid = set(['TleilaxuBot', 'CopyBot'])

    def __init__(self, *args):
        super().__init__(*args)

        if not self._bot_copies:
            for f_info in inspect.stack():
                try:
                    all_bots = f_info.frame.f_locals["bots"]
                    break
                except KeyError:
                    pass
                finally:
                    del f_info
            for bot_class in all_bots:
                name = bot_class.__name__
                if name != __class__.__name__:
                    self._bot_copies[name] = bot_class(-1, self.end_score)

        for bot in self._bot_copies.values():
            bot.index = self.index
        self.find_bots()

    def find_bots(self):
        for f_info in inspect.stack():
            try:
                self.bots = f_info.frame.f_locals["game_bots"]
                self.bot_names = [bot.__class__.__name__ for bot in self.bots]
                break
            except KeyError:
                pass
            finally:
                del f_info

    def update_state(self, current_throws):
        self.bot_names = [bot.__class__.__name__ for bot in self.bots]
        self.current_throws = current_throws
        for i, bot_name in enumerate(self.bot_names):
            if i != self.index:
                self._bot_copies[bot_name].update_state(current_throws)

    def make_throw(self, scores, last_round):
        self.bot_names = [bot.__class__.__name__ for bot in self.bots]
        other_scores = [
            (s, i) for i, s in enumerate(scores) 
            if self.bot_names[i] not in self._avoid
        ]
        ind = [i for s, i in other_scores if s == max(other_scores)[0]][0]

        throws = 0
        try:
            winner_bot = self._bot_copies[self.bot_names[ind]]
            for answer in winner_bot.make_throw(scores, last_round):
                throws += 1
                yield answer
                winner_bot.update_state(self.current_throws)
        except Exception:
            while throws < 5:
                yield True
                throws += 1
            yield False
\$\endgroup\$
2
  • \$\begingroup\$ That's certainly more efficient. So it's inspired me to write another variant that's even more efficient. I started by creating my own bots as and when needed, rather than creating all of them at start of day. Then I optimised the code to find the leader, and stripped down the update_state() code so that it only updated the bot we were using (this might break if bots kept track of state updates, but AFAICS there aren't any that do). All this got the CPU time down to about one-tenth of TleilaxuBot or half that of CopyBot , but the win rate still wasn't any better ... \$\endgroup\$
    – DaniO
    Feb 11, 2019 at 22:49
  • \$\begingroup\$ Then I decided to change the strategy a little; instead of asking the bot that's in the lead in the current game, my new variant CloneBot asks its own instance of the bot that's got the best overall win rate ... with remarkable results :) \$\endgroup\$
    – DaniO
    Feb 11, 2019 at 22:54
1
2

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.