Bunco Simulator

bunco.py at trunk

File bunco.py artifact a97ef9 on branch trunk


# Test
import csv, random, sqlite3
from statistics import median

teammate_lookup = { 0: 2, 1: 3, 2: 0, 3: 1 }

fuzzydie_holder = 'x'

table_move_callback = None

def TurnInProgress():
    return -1

def roll_dice():
    dice = []
    for _ in range(3):
        dice.append(random.randint(1,6))
    return dice

class Player:
    def __init__(self, name, phone, dex, math, speed):
        self.name = name
        self.smsnumber = phone
        self.dex = dex
        self.math_comprehension = math
        self.roll_speed = speed
        self.max_streak = 0
        self.round_bunco_counts = [0]
        self.round_scores = [0]
        self.round_roll_counts = [0]
        self.personal_roll_scores = [0]
        self.round_wins = [0]
        self.turn_progress = 0
        self.current_streak = 0
        self.max_fuzzydie_streak = 0
        self.current_fuzzydie_streak = 0
        self.rolled_bunco = False
        self.current_roll = []
    
    def __repr__(self):
        return f"<Player {self.name}: " \
            + f"\tscores\t\t{self.round_scores}>" \
            + f"\troll counts\t{self.round_roll_counts}>"

    def __str__(self):
        return self.name

    def prep_new_round(self):
        self.round_scores.append(0)
        self.round_roll_counts.append(0)
        self.personal_roll_scores.append(0)
        self.round_bunco_counts.append(0)
        self.round_wins.append(0)
        self.turn_progress = 0
        self.current_streak = 0
    
    def average_contrib_pct(self):
        pcts = []
        for n in range(len(self.round_scores)):
            if self.round_scores[n] > 0:
                pcts.append(self.personal_roll_scores[n] / self.round_scores[n])
            else:
                pcts.append(0)
        return sum(pcts) / len(pcts)

    def score_last_roll(self):
        desired_num = Game.current_round() % 6
        desired_num = 6 if desired_num == 0 else desired_num
    
        roll_score = 0
        
        if all(die == desired_num for die in self.current_roll):
            # Bunco!
            roll_score = 21
        elif all(die == self.current_roll[0] for die in self.current_roll):
            # All three dice match, but not Bunco
            roll_score = 5
        else:
            for die in self.current_roll:
                roll_score += 1 if die == desired_num else 0

        if roll_score > 0:
            self.current_streak += 1
            self.max_streak = max(self.current_streak, self.max_streak)
            self.round_scores[Game.current_round() - 1] += roll_score
            self.personal_roll_scores[Game.current_round() - 1] += roll_score
            if roll_score == 21:
                self.round_bunco_counts[Game.current_round() - 1] += 1
                self.rolled_bunco = True
        else:
            self.current_streak = 0
        
        log_roll(self, self.current_roll, roll_score)
        return roll_score
        
    def tick(self):
        global fuzzydie_holder
        result = TurnInProgress()

        if fuzzydie_holder is self:
            self.current_fuzzydie_streak += 1
            self.max_fuzzydie_streak = max(self.max_fuzzydie_streak, self.current_fuzzydie_streak)
        else:
            self.current_fuzzydie_streak = 0

        if self.turn_progress < 25:
            # Grabbing the dice
            # TODO: Incorporate DEXTERITY stat
            self.turn_progress += random.randint(12,25)
        elif self.turn_progress < 50:
            # Rolling the dice
            # TODO: Incorporate ROLL SPEED stat
            self.turn_progress += random.randint(12,25)
        elif self.turn_progress < 75:
            # Reading the numbers
            # TODO: Incorporate MATH COMPREHENSION stat
            if not self.current_roll:
                self.current_roll = roll_dice()
                self.round_roll_counts[Game.current_round() - 1] += 1
                log(self, f"{self.name} rolled the dice")
            self.turn_progress += random.randint(12,25)
        else:
            # Finished reading the numbers -- good job, eyeballs and brain!
            result = self.score_last_roll()
            self.current_roll = []
            self.turn_progress = 0
        return result

class Table:
    def __init__(self):
        self.table_number = 0
        self.team1_score = 0
        self.team2_score = 0
        self.players = []
        self.active_player = -1

    def __repr__(self):
        names = ", ".join([p.name for p in self.players])
        return f"<Table {self.table_number}: {names}>"

    def __str__(self):
        if self.table_number == 1:
            return "Head Table"
        else:
            return f"Table {self.table_number}"

    def update_teammate_score(self, score):
        teammate = self.players[teammate_lookup[self.active_player]]
        teammate.round_scores[Game.current_round() - 1] += score

    def tick(self):
        if self.active_player == -1:
            # First tick for the table this round
            self.active_player = random.randint(1, len(self.players)) - 1

        result = self.players[self.active_player].tick()

        if result != TurnInProgress():
            # Player rolled
            if result > 0:
                if (self.active_player % 2) == 0:
                    self.team1_score += result
                else:
                    self.team2_score += result

                self.update_teammate_score(result)
            else:
                self.active_player += 1
                if self.active_player > (len(self.players) - 1): self.active_player = 0
    
    def roll_off(self):
        """Attempt to settle a tie by going round the table once"""
        self.active_player = 0
        
        # Let the first player go until they roll for 0 points
        while self.active_player == 0:
            self.tick()
            Game.increment_tick()
        # Now let the rest do the same
        while self.active_player != 0:
            self.tick()
            Game.increment_tick()

    def losers(self):
        if self.team1_score > self.team2_score:
            return self.players[1::2]
        else:
            return self.players[0::2]
    
    def winners(self):
        if self.team1_score > self.team2_score:
            return self.players[0::2]
        else:
            return self.players[1::2]
    
    def swap_for_winners(self, new_players):
        if self.team1_score > self.team2_score:
            # Team 1 (evens) won
            winners = self.players[0::2]
            # Player 1 move to spot 0
            self.players[0] = self.players[1]
        else:
            # Team 2 (odds) won
            winners = self.players[1::2]
            # Player 2 move to spot 3
            self.players[3] = self.players[2]
        # Replace middle two players
        self.players[1:3] = new_players
        return winners

    def swap_for_losers(self, new_players):
        if self.team1_score < self.team2_score:
            # Team 1 (evens) lost
            losers = self.players[0::2]
            # Player 1 move to spot 0
            self.players[0] = self.players[1]
        else:
            # Team 2 (odds) lost
            losers = self.players[1::2]
            # Player 2 move to spot 3
            self.players[3] = self.players[2]
        # Replace middle two players
        self.players[1:3] = new_players
        return losers

    def notch_wins(self):
        for player in self.winners():
            player.round_wins[Game.current_round() - 1] = 1
    
    def prep_new_round(self):
        self.team1_score = 0
        self.team2_score = 0
        self.active_player = -1
        for player in self.players:
            player.prep_new_round()
    
    def get_player_situation(self, player):
        player_index = self.players.index(player)
        if player_index % 2 == 0:
            opponents = self.players[1::2]
            opponent_score = self.team2_score
        else:
            opponents = self.players[0::2]
            opponent_score = self.team1_score
        teammate = self.players[teammate_lookup[player_index]]

        return {'teammate': teammate, 
                'opponents': opponents,
                'opponent_score': opponent_score}


def assign_teams(player_list):
    players_per_table = 4
    tables = []
    random.seed()

    if len(player_list) % players_per_table != 0:
        print("Wrong number of players!")
        return tables
    else:
        table_count = len(player_list) // players_per_table
        random.shuffle(player_list)
        tables = [Table() for _ in range(table_count)]

        for n in range(table_count):
            first = n * players_per_table
            stop_before = first + players_per_table
            tables[n].players = player_list[first:stop_before]
            tables[n].table_number = n + 1
        
        return tables

def load_players(filename):
    players = []

    with open(filename) as tsvfile:
        tsvreader = csv.reader(tsvfile)
        for line in tsvreader:
            players.append(Player(line[0],line[1],0,0,0))
    
    return players

class Game:
    cur_tick = 1
    cur_round = 1
    
    def __init__(self, playerfile):
        self.players = load_players(playerfile)
        self.tables = assign_teams(self.players)

    def tick(self):
        global fuzzydie_holder
        for table in self.tables:
            table.tick()
        
        bunco_rollers = [p for p in self.players if p.rolled_bunco is True]
        
        # If multiple people rolled Bunco this tick, and one of them already has the
        # fuzzy die, they retain it.
        # Otherwise, the last person in the list gets the fuzzy die.
        if bunco_rollers:
            if len(bunco_rollers) == 1:
                if bunco_rollers[0] is not fuzzydie_holder:
                    log("all",f"{bunco_rollers[0]} claimed the fuzzy die!")
                    fuzzydie_holder = bunco_rollers[0]
                else:
                    log("all",f"{bunco_rollers[0]} rolled a Bunco but already has the fuzzy die!")
            else:
                for luckyduck in bunco_rollers:
                    if luckyduck is not fuzzydie_holder:
                        log(luckyduck,f"{luckyduck} attempted to claim the fuzzy die!")
                
                if fuzzydie_holder not in bunco_rollers:
                    fuzzydie_holder = bunco_rollers[-1]
                    log(fuzzydie_holder, f"{fuzzydie_holder} siezed the fuzzy die!!")
                else:
                    log(fuzzydie_holder, f"{fuzzydie_holder} retained the fuzzy die!!")
            
            for player in bunco_rollers:
                player.rolled_bunco = False # Reset flag
    
        self.increment_tick()
    
    @classmethod
    def current_tick(cls):
        return cls.cur_tick
    
    @classmethod
    def increment_tick(cls):
        cls.cur_tick += 1

    @classmethod
    def current_round(cls):
        return cls.cur_round

    @classmethod
    def increment_round(cls):
        cls.cur_round += 1

    def print_status(self):
        for n, table in enumerate(self.tables):
            print(f"== TABLE {n+1} == Team 1:{table.team1_score} pts, Team 2:{table.team2_score} pts")
            for player in table.players:
                print(f"    {player.name} {player.round_scores[Game.current_round() - 1]} points, streak {player.max_streak} buncos {sum(player.round_bunco_counts)}")
    
    def average_total_score(self):
        all_scores = [sum(p.round_scores) for p in self.players]
        return sum(all_scores) / len(all_scores)

    def median_total_score(self):
        all_scores = [sum(p.round_scores) for p in self.players]
        
        return median(all_scores)

    def prep_next_round(self):
        # losers from head table move to next table
        headtable_losers = self.tables[0].losers()
        log_table_move(headtable_losers, "lost", self.tables[0], self.tables[1])
        if callable(table_move_callback):
            for player in headtable_losers:
                table_move_callback(player, "lost", self.tables[0], self.tables[1])
        round_winners = self.tables[1].swap_for_winners(headtable_losers)

        # winners from other tables move to next table
        for n in range(2, len(self.tables)):
            log_table_move(round_winners, "won", self.tables[n-1], self.tables[n])
            if callable(table_move_callback):
                for player in round_winners:
                    table_move_callback(player, "won", self.tables[n-1], self.tables[n])
            round_winners = self.tables[n].swap_for_winners(round_winners)
        
        # last set of winners moves to head table
        log_table_move(round_winners, "won", self.tables[-1], self.tables[0])
        if callable(table_move_callback):
            for player in round_winners:
                table_move_callback(player, "won", self.tables[-1], self.tables[0])
        self.tables[0].swap_for_losers(round_winners)

        for table in self.tables:
            table.prep_new_round()

        self.increment_round()

    def play_one_round(self):
        # Go until one of the head table teams reaches 21 pts
        while max(self.tables[0].team1_score, self.tables[0].team2_score) < 21:
            self.tick()

        log("all", "BUNCO!! A team at the Head Table has hit 21 points.")
        
        # Finish up scoring for any players that have unscored rolls
        for table in self.tables:
            curplayer = table.players[table.active_player]
            if curplayer.current_roll:
                log(curplayer, f"{curplayer} finishing up [their] turn")
                while curplayer.current_roll:
                    table.tick()
                    self.increment_tick()
        
        # Settle ties at each table by doing a roll-off as many times as needed
        for table in self.tables:
            log('all', f"{table}: Team 1 {table.team1_score} pts, Team 2 {table.team2_score} pts")
            while table.team1_score == table.team2_score:
                log('all', f"{table} having a roll-off to resolve a tie")
                table.roll_off()
                log('all', f"{table}: Team 1 {table.team1_score} pts, Team 2 {table.team2_score} pts")
            
            table.notch_wins()
        
    def prizes(self):
        prizelist = {}
        scores = [sum(p.round_scores) for p in self.players]
        wins = [sum(p.round_wins) for p in self.players]
        losses = [Game.current_round() - w for w in wins]
        buncos = [sum(p.round_bunco_counts) for p in self.players]
        contrib_pcts = [p.average_contrib_pct() for p in self.players]
        rolls = [sum(p.round_roll_counts) for p in self.players]
        streaks = [p.max_streak for p in self.players]
        fuzzy_streaks = [p.max_fuzzydie_streak for p in self.players]
        avg_diffs = [sum(p.round_scores) - self.average_total_score() for p in self.players]
        median_diffs = [sum(p.round_scores) - self.median_total_score() for p in self.players]
        smallest_avg_diff = avg_diffs[list(map(abs,avg_diffs)).index(min(list(map(abs,avg_diffs))))]
        smallest_median_diff = median_diffs[list(map(abs,median_diffs)).index(min(list(map(abs,median_diffs))))]

        # Build a list of prizes and winners, allowing for ties
        prizelist["Highest Score"] = f"{', '.join([str(p) for p in self.players if sum(p.round_scores) == max(scores)])} ({max(scores)})"
        prizelist["Lowest Score"] = f"{', '.join([str(p) for p in self.players if sum(p.round_scores) == min(scores)])} ({min(scores)})"
        prizelist["Most Wins"] = f"{', '.join([str(p) for p in self.players if sum(p.round_wins) == max(wins)])} ({max(wins)})"
        prizelist["Most Losses"] = f"{', '.join([str(p) for p in self.players if Game.current_round() - sum(p.round_wins) == max(losses)])} ({max(losses)})"
        prizelist["Most Buncos"] = f"{', '.join([str(p) for p in self.players if sum(p.round_bunco_counts) == max(buncos)])} ({max(buncos)})"
        prizelist["Highest Team Contributor"] = f"{', '.join([str(p) for p in self.players if p.average_contrib_pct() == max(contrib_pcts)])} ({max(contrib_pcts):.2%})"
        prizelist["Most Rolls"] = f"{', '.join([str(p) for p in self.players if sum(p.round_roll_counts) == max(rolls)])} ({max(rolls)})"
        prizelist["Longest Roll Streak"] = f"{', '.join([str(p) for p in self.players if p.max_streak == max(streaks)])} ({max(streaks)})"
        prizelist["Fewest Rolls"] = f"{', '.join([str(p) for p in self.players if sum(p.round_roll_counts) == min(rolls)])} ({min(rolls)})"
        prizelist["Last Fuzzy Die Holder"] = fuzzydie_holder.name
        prizelist["Longest Time with Fuzzy Die"] = f"{', '.join([str(p) for p in self.players if p.max_fuzzydie_streak == max(fuzzy_streaks)])} ({max(fuzzy_streaks)})"
        prizelist["Most Average Total Score"] = f"{', '.join([str(p) for p in self.players if abs(sum(p.round_scores) - self.average_total_score()) == abs(smallest_avg_diff)])} ({smallest_avg_diff})"
        prizelist["Closest to Median Total Score"] = f"{', '.join([str(p) for p in self.players if abs(sum(p.round_scores) - self.median_total_score()) == abs(smallest_median_diff)])} ({smallest_median_diff})"

        return prizelist
    
log_db = sqlite3.connect("bunco.sqlite")
log_dbc = log_db.cursor()

def run_query(*args):
    log_dbc.execute(*args)
    log_db.commit()

run_query('DROP TABLE IF EXISTS `bunco_log`')
run_query('CREATE TABLE `bunco_log` (id PRIMARY KEY, tick_number, round, player_name, type, message)')

def log_roll(player, dice, score):
    msg = f"{player.name} comprehended that [their] roll of {dice} = {score} points"
    query = """INSERT INTO bunco_log(tick_number, round, player_name, type, message) 
                VALUES(?, ?, ?, ?, ?)"""
    run_query(query, (Game.current_tick(), Game.current_round(), str(player), 'roll', msg))

def log_table_move(players, reason, table_from, table_to):
    message = f"Having {reason} the last round, {players[0].name} and {players[1].name} move from {table_from} to {table_to}."
    query = """INSERT INTO bunco_log(tick_number, round, player_name, type, message) 
                VALUES(?, ?, ?, ?, ?)"""
    run_query(query, (Game.current_tick(), Game.current_round(), 'all', 'general', message))

def log(player, message):
    query = """INSERT INTO bunco_log(tick_number, round, player_name, type, message) 
                VALUES(?, ?, ?, ?, ?)"""
    run_query(query, (Game.current_tick(), Game.current_round(), str(player), 'general', message))