5
\$\begingroup\$

Back with another Reddit bot. This one provides games of Hangman to an arbitrary number of players simultaneously. Users interface with the bot by mentioning it for a new game. Once that game is started, the user guesses continuously and the bot replies until win, loss, or forfeit. A user is limited to playing only their own game and there is no branching..

To achieve this, I keep track of active games with a dict of <user,game>, active_games, which is saved to a file in json format every time it changes. The program can be restarted such that it loads the active games from file. This persistence requires that I convert between Hangman instances and dicts, and I'm not sure I've done it the best way. Additionally, whenever a game ends it is removed from active_games and written to another file storing all past games.

Please feel free to comment on anything.

import json
import os
import random

import praw
import requests

FORFEIT = 'forfeit'
WIN = 'You win! Big-brained, you are.'
LOSS = 'You lose. Try again.'
MIN_LEN = 4
INIT_LIVES = 5

class Hangman:
    """represents the state of a game of Hangman"""

    def __init__(self):
        self.secret = random_word()
        self.lives = INIT_LIVES
        self.word_state = ['_'] * len(self.secret)
        self.mistakes = []
    
    @classmethod
    def fromdict(cls, dictionary):
        """dictionary to Hangman copy/convert constructor""" 
        instance = cls()
        for key in dictionary:
            setattr(instance, key, dictionary[key])
        return instance

    def process_guess(self, guess_body):
        """requires that guess_body is single character: modifies word_state to fill in guess matches"""
        for i in range(0, len(self.secret)):
            if self.secret[i] == guess_body:
                self.word_state[i] = guess_body
                
    def word_correct(self, guess):
        return guess == self.secret
    
    def record_mistake(self, mistake_body):
        self.mistakes.append(mistake_body)
        self.lives -= 1

    def display_contents(self):
        """return a formatted markdown string containing a report on hangman attributes"""
        reply = ''
        reply += '\n\nlives: ' + str(self.lives) + '\n\n#'
        for char in self.word_state:
            reply += char + ' '
        reply += '\n\nmistakes: '
        reply += ', '.join(self.mistakes)
        return reply

def random_word():
    """request one random word from API. If len of word at least MIN_LEN letters, return it (str). Otherwise, request another."""
    while True:
        r = requests.get('https://random-word-api.herokuapp.com/word', {'number' : 1})
        r.raise_for_status()
        word = r.json()[0]
        if len(word) >= MIN_LEN:
            return word

def authenticate():
    r = praw.Reddit('hangman', user_agent = "hangmanbot")
    return r

def run_bot(reddit, active_games):
    # concern: maybe update_active_games_file should be done in same fn as archiving
    unread_items = []
    for item in reddit.inbox.unread(limit=None):
        if bot_mentioned(item):
            start_new_game(item, active_games)
            update_active_games_file(item, active_games)
        else:
            try:
                continue_game(item, active_games)
                update_active_games_file(item, active_games)
            except Exception as e: print(e)
        unread_items.append(item)
    reddit.inbox.mark_read(unread_items)   
    

def bot_mentioned(item):
    return 'u/hangman_bot' in item.body


def start_new_game(item, active_games):
    """reply to item with a comment containing a new Hangman game and remember it."""
    if item.author.name not in active_games:
        new_game = Hangman()
        active_games[item.author.name] = new_game
        item.reply(new_game.display_contents())
        

def continue_game(guess, active_games):
    """continue a game by replying to guess with the updated hangman state."""
    game = active_games[guess.author.name]
    guess_content = guess.body.replace(' ','').replace('\n','').lower()
    if game.word_correct(guess_content):
        remove_and_archive_game(guess, active_games)
        guess.reply(WIN)
    elif guess_content in game.secret: # possibly make this work for substrings len > 1
        game.process_guess(guess_content)
        if game.secret == ''.join(game.word_state):
            remove_and_archive_game(guess, active_games)
            guess.reply(WIN)
        else:
            guess.reply('Correct!' + game.display_contents())
    elif game.lives == 1 or FORFEIT in guess_content:
        remove_and_archive_game(guess, active_games)
        guess.reply(LOSS + '\n\nWord: ' + game.secret)
    else:
        game.record_mistake(guess_content)
        guess.reply('Incorrect!' + game.display_contents())

def remove_and_archive_game(guess, active_games):
    """remove guess author's game from active, place entry in archive file"""
    finished_game = active_games.pop(guess.author.name)
    if not os.path.isfile('hangmanbot/archived_games.txt'):
        with open('hangmanbot/archived_games.txt', 'w') as f:
            json.dump({guess.author.name : [finished_game.__dict__]}, f)
    else: 
        with open('hangmanbot/archived_games.txt', 'r+') as f:
            archived_games = json.load(f)
            if guess.author.name not in archived_games:
                archived_games[guess.author.name] = [finished_game.__dict__]
            else:
                archived_games[guess.author.name].append(finished_game.__dict__)
            # seek(0), dump, truncate completely overwrites the file contents.
            f.seek(0)
            json.dump(archived_games, f)
            f.truncate()

def update_active_games_file(item, active_games): 
    """update the save file by writing a new active game or modifying an existing game."""
    copied = dict()
    for key in active_games:
        copied[key] = active_games[key].__dict__
    with open('hangmanbot/active_games.txt', 'w') as f:
        json.dump(copied, f)

def get_active_games():
    """return a dict of <username, Hangman> corresponding to active games."""
    if not os.path.isfile("hangmanbot/active_games.txt"):
        return dict()
    else:
        with open("hangmanbot/active_games.txt") as f:
            dict_with_dicts = json.load(f)
            dict_with_objects = dict()
            for key in dict_with_dicts:
                dict_with_objects[key] = Hangman.fromdict(dict_with_dicts[key])
            return dict_with_objects

def main():
    reddit = authenticate()
    active_games = get_active_games()
    while True:
        run_bot(reddit, active_games)

## end definitions
## begin executions
if __name__ == '__main__':
    main()
```
\$\endgroup\$
2
  • \$\begingroup\$ Do you really need an API for a random word? The API provider has provided a means to host it yourself, along with the source code. They have also provided a call that gives you all the words at once, which means you can put them in a file and generate random words yourself. \$\endgroup\$
    – spyr03
    Commented Aug 6, 2020 at 16:17
  • \$\begingroup\$ @spyr03 yes, I suppose the request isn't really necessary and I could download all the words. Do you have any other comments? \$\endgroup\$
    – ShokoN
    Commented Aug 6, 2020 at 18:35

1 Answer 1

1
\$\begingroup\$

First of all, this is all pretty good. If it was short, it would be fine. Because it's long, it could use neater organization.

My biggest feedback is if you separate

  1. Interacting with the reddit API
  2. Local persistence to and from files (you may want to have separate persistence for ongoing and archived games)
  3. Messages to and from players (make them an explicit concept not linked to the reddit API)
  4. The core hangman game

then the code will be easy to understand. It will also be easy to reuse, so you can make it cooler (switch out games, add a discord bot to play the same games, change persistence).

Other than that, this looks fine. Mostly, it's lacking robustness and dealing with errors, which ends up being pretty important for web software and human input. You could add robustness, and learn about that, which will help if you want to be a professional programmer. Or you could decide it's no fun and ignore it. But do keep in mind if you planning to run this in a loop forever, that there are currently likely ways you'll end up spamming people thousands of messages, which will suck for them and you.

  • Learn about all the things that can go wrong if you control-C your program halfway though, or if you encounter an exception halfway through. Will your database be corrupt and everyone will lose their game? Will you send people two messages? Will you be unable to get new messages and send them a new message every time your script runs?
  • What happens if reddit returns an error? Do you send people multiple messages? One common goal is to make "partial progress"--if you can read messages from 98 people, you should process those 98 turns and mark them as read, not fail for everyone. The errors might be from reddit, or they might be from you.

You've chosen to store JSON. This is fine, especially if you want to manually debug. There are also built-in serialization methods in Python (pickle, shelve, and marshall). You may encounter some problems if you get more players, runtime errors, or run for a long time.

  • If you start having a large number of updates, you'll find that writing and reading the whole file every time becomes a problem. This is a whole area to learn about, I can't really give any concise advice. It's definitely a problem you will run into many times, so it's worth learning the options and common solutions.

I didn't see any defensive programming in particular, so you might want to think about that or security. I also don't see any specific problems, just mentioning that it's generally good to think about. Keep in mind that a user typing in really weird guesses (doesn't have to be malicious, think just some weird unicode smileys) could crash your game or corrupt the database.

\$\endgroup\$

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.