import enum import random from collections import namedtuple import cardcast_api # TODO: rando # TODO: keep track of where cards come from purge from hand when deck remove class events(enum.Enum): quit, nick_change, status, start, ready, unready, kill, join, leave, players, deck_add, deck_add_random, deck_remove, deck_list, limit, card, cards = range(17) class limit_types(enum.Enum): points, rounds = range(2) Deck = namedtuple('Deck', ['code', 'name', 'author', 'call_count', 'response_count', 'calls', 'responses']) Limit = namedtuple('Limit', ['type', 'number']) class Player: def __init__(self, nick): self.nick = nick self.hand = [] self.points = 0 def __repr__(self): if __name__ == '__main__': return 'Player(%s)' % repr(self.nick) else: return '%s.Player(%s)' % (__name__, repr(self.nick)) def __hash__(self): return id(self) class Error: pass def game(send, notice, voice, devoice, get_event): def error(message): send('Error: %s' % message) def errwrapper(message, f, *args, **kwargs): try: return f(*args, **kwargs) except Exception as err: error(message % ('%s, %s' % (type(err), err))) return Error def add_player(nick): nonlocal players assert nick not in players players[nick] = Player(nick) def remove_player(nick): nonlocal players del players[nick] def change_player_nick(old, new): nonlocal players player = players[old] del players[old] player.nick = new players[new] = player def list_players(): nonlocal players send(', '.join(sorted(players))) def add_deck(code): nonlocal decks assert code not in decks # First get info for the deck we're adding info = cardcast_api.info(code) # Extract the information we want to keep of the deck name = info['name'] author = info['author']['username'] call_count = int(info['call_count']) response_count = int(info['response_count']) # Get cards calls, responses = cardcast_api.cards(code) # Preprocess calls so that ___ becomes only one _ # _ are indicated by splitting the card at that point, e.g. # ['foo ', '.'] is "foo _." # Two blanks a row will thus be ['foo ', '', '.'] # We can't just remove every single '', since it can be valid # at the start and the end of a card for i in range(len(calls)): call = [] for index, part in enumerate(calls[i]): if index == 0 or index == len(calls[i]) - 1: # Always pass these ones through call.append(part) elif part == '': # Remove '' in the middle continue else: call.append(part) calls[i] = call # Add a new deck to list of decks decks[code] = Deck( code = code, name = name, author = author, call_count = call_count, response_count = response_count, calls = calls, responses = responses ) def get_random_deck_code(): nonlocal cardcast_deck_count # Provide the count on subsequent calls # First time around cardcast_deck_count will be None, so it # gets requested from Cardcast, like if we didn't pass the # `count` parameter # This will update cardcast_deck_count for each call # unnecessarily, but I think it simplifies the code and is not # too bad code, cardcast_deck_count = cardcast_api.random_code(count = cardcast_deck_count) return code def remove_deck(code): nonlocal decks del decks[code] def list_decks(): nonlocal decks if len(decks) == 0: send('No decks') return for deck in decks.values(): call_count = deck.call_count calls_left = len(deck.calls) calls = str(call_count) if call_count == calls_left else '%i/%i' % (calls_left, call_count) response_count = deck.response_count responses_left = len(deck.responses) responses = str(response_count) if response_count == responses_left else '%i/%i' % (responses_left, response_count) send('%s (%s, by %s, %s black, %s white)' % ( deck.name, deck.code, deck.author, calls, responses )) def deck_add_handler(code): nonlocal decks if code not in decks: errwrapper('Failure adding deck: %s (%%s)' % code, add_deck, code) else: send('Deck already added') def deck_add_random_handler(): nonlocal decks # Let's hope this never bites us in the butt while True: code = errwrapper('Failure getting random code for a deck. (%s)', get_random_deck_code) if code is Error: return if code not in decks: break send('That was weird, got %s randomly but it was already added' % code) errwrapper('Failure adding deck: %s (%%s)' % code, add_deck, code) def common_handler(event, args): nonlocal players, decks, limit if event == events.kill: send('Stopping game') return no_game elif event == events.quit: return quit elif event == events.nick_change: old, new = args if old in players: change_player_nick(old, new) elif event == events.join: nick, = args if nick not in players: add_player(nick) voice(nick) send('%s has joined' % nick) else: send('%s has already joined' % nick) elif event == events.leave: nick, = args if nick not in players: send('No such player %s' % nick) elif errwrapper('Could not remove player %s (%%s)' % nick, remove_player, nick) is not Error: devoice(nick) send('%s has left the game' % nick) elif event == events.players: list_players() elif event == events.deck_add: code, = args deck_add_handler(code) elif event == events.deck_add_random: deck_add_random_handler() elif event == events.deck_remove: code, = args if code in decks: errwrapper('Failure removing deck %s (%%s)' % code, remove_deck, code) else: send('No such deck %s' % code) elif event == events.deck_list: list_decks() elif event == events.limit: if len(args) == 0: limit_type = {limit_types.rounds: 'rounds', limit_types.points: 'points'}[limit.type] send('Limit is %i %s' % (limit.number, limit_type)) else: limit_type, number = args limit = Limit(limit_type, number) limit_type = {limit_types.rounds: 'rounds', limit_types.points: 'points'}[limit.type] send('Limit set to %i %s' % (limit.number, limit_type)) elif event == events.card or event == events.cards: # Ignore selecting and listing cards if it's not available pass elif event == events.ready or event == events.unready: # Ignore readiness commands by default pass else: error('Unknown event type: %s' % event) def no_game(): nonlocal players, decks, limit, round_number, round_call_card, czar, card_choices if players is not None: devoice(players) players = {} decks = {} limit = Limit(limit_types.points, 5) round_number = 1 round_call_card = None czar = None card_choices = None while True: event, *args = get_event() if event == events.status: send('Idle') elif event == events.start: nick, *rest = args add_player(nick) voice(nick) send('%s started a game, !join to join!' % nick) expert = False if len(rest) == 0 or rest[0] == 'default': send('Adding the default CAH deck (A5DCM)') deck_add_handler('A5DCM') elif rest[0] == 'offtopia': send('Adding the default CAH deck (A5DCM), offtopia injoke deck (PXWKC), and three random decks') deck_add_handler('A5DCM') deck_add_handler('PXWKC') deck_add_random_handler() deck_add_random_handler() deck_add_random_handler() elif rest[0] == 'expert': expert = True else: send('Unknown preset %s' % rest[0]) if not expert: send('Once you are ready to start the game, everyone send !ready') return game_setup elif event == events.quit: return quit else: send('Start with !start') def game_setup(): nonlocal players players_ready = set() while True: if len(players) == 0: send('Lost all players, quiting game setup') return no_game players_unready = [i for i in players.values() if i not in players_ready] if len(players_unready) == 0: break event, *args = get_event() if event == events.status: if len(players_ready) == 0: send('Game setup') else: send('Game setup, waiting for %s to be ready' % ', '.join(i.nick for i in players_unready)) elif event == events.start: break elif event == events.ready: nick, = args # Ignore if not in the game if nick not in players: continue player = players[nick] if player not in players_ready: players_ready.add(player) elif event == events.unready: nick, = args # Ignore if not in the game if nick not in players: continue player = players[nick] if player in players_ready: players_ready.remove(player) else: r = common_handler(event, args) if r is not None: return r if len(players) < 2: send('Not enough players') return game_setup else: return setup_round def total_calls(): nonlocal decks return sum(len(deck.calls) for deck in decks.values()) def total_responses(): nonlocal decks return sum(len(deck.responses) for deck in decks.values()) def deal_call(): nonlocal decks deck_objs = list(decks.values()) while True: deck = random.choice(deck_objs) if len(deck.calls) != 0: break # See comment about mutation in deal_responses() index = random.randrange(len(deck.calls)) return deck.calls.pop(index) def deal_responses(need_responses): nonlocal decks responses = [] deck_objs = list(decks.values()) for i in range(need_responses): while True: deck = random.choice(deck_objs) if len(deck.responses) != 0: break # We generate an index and pop that, since that makes # it easier to mutate the list in place index = random.randrange(len(deck.responses)) responses.append(deck.responses.pop(index)) # Shuffle the responses at the end, as otherwise the first # cards are more likely to have come from small decks than # the last cards random.shuffle(responses) return responses def setup_round(): nonlocal players, round_call_card, czar, card_choices # Select a czar randomly, if we need to if czar not in players.values(): czar = random.choice(list(players.values())) # Clear out previous round's cards card_choices = {} # Check that we have a call card for next round, should we need one if round_call_card is None: available_calls = total_calls() if available_calls == 0: send('Need a black card, none available. Add decks and continue with !start') return game_setup # Select call card for the next round round_call_card = deal_call() # Find out how many response cards we need need_responses = 0 for player in players.values(): # Don't deal cards to the czar this round if player is czar: continue if len(player.hand) < 10: need_responses += 10 - len(player.hand) need_responses += player.hand.count(None) # If we don't have enough, kick back to setup available_responses = total_responses() if available_responses < need_responses: send('Need %i white cards, only %i available. Add decks and continue with !start' % (need_responses, available_responses)) return game_setup # Get the cards responses = deal_responses(need_responses) # Add responses to players' inventories for player in players.values(): # We skipped the czar in the counts, so skip here too if player is czar: continue while len(player.hand) < 10: player.hand.append(responses.pop()) for index in range(10): if player.hand[index] is None: player.hand[index] = responses.pop() return top_of_round def sanitize(text): return ''.join(i if ord(i) >= 32 and ord(i) != 127 else '^' + chr(ord(i) ^ 64) for i in text) def send_cards(nick): nonlocal players cards = ' | '.join('%i: [%s]' % (index, sanitize(card)) for index, card in enumerate(players[nick].hand)) notice(nick, cards) def combine_cards(call, responses): def handle_call_part(call_part): nonlocal responses r = [] after_dollar = False for char in call_part: if after_dollar and ord('0') <= ord(char) <= ord('9'): # Handle $0 .. $9 # Hopefully we won't run into more backreferences # in one card index = int(char) if 0 <= index < len(responses): r.append(responses[index]) else: # Not valid backreference, copy verbatim r.append('$' + char) after_dollar = False elif after_dollar: # Wasn't a backreference, copy verbatim r.append('$' + char) after_dollar = False elif char == '$': after_dollar = True else: r.append(char) return sanitize(''.join(r)) combined = [handle_call_part(call[0])] for i in range(len(call) - 1): combined.append('[' + sanitize(responses[i]) + ']') combined.append(handle_call_part(call[i + 1])) return ''.join(combined) def top_of_round(): nonlocal players, round_number, round_call_card, czar, card_choices choosers = [i for i in players.values() if i is not czar] send('Round %i. %s choose your cards' % (round_number, ', '.join(i.nick for i in choosers))) send('[%s]' % '_'.join(sanitize(part) for part in round_call_card)) for nick in players: if players[nick] is not czar: send_cards(nick) while len(choosers) > 0: # Make sure that if a chooser leaves, they won't be waited on choosers = [i for i in choosers if i in players.values()] if len(players) < 2: send('Not enough players to continue, quiting game') if czar not in players.values(): send('Czar left the game, restarting round') return setup_round event, *args = get_event() if event == events.status: send('Waiting for %s to choose' % ', '.join(i.nick for i in choosers)) elif event == events.start: send('Game already in progress') elif event == events.card: nick, choices = args # Ignore those not in the game if nick not in players: continue player = players[nick] if player is czar: notice(nick, 'Czar can\'t choose now') continue elif player not in choosers and player not in card_choices: notice(nick, 'You\'ll get to choose next round') continue # Round call card has N parts. Between each of # those parts goes one response card. Therefore # there should be N - 1 response cards if len(choices) != len(round_call_card) - 1: notice(nick, 'Select %i card(s)' % (len(round_call_card) - 1)) continue selected_cards = [] for choice in choices: if 0 <= choice < len(player.hand): if choice not in selected_cards: selected_cards.append(choice) else: notice(nick, 'Can\'t play the same card twice') break else: notice(nick, '%i not in your hand' % choice) break if len(selected_cards) != len(choices): # Failed to use some choice continue card_choices[player] = selected_cards if player in choosers: choosers.remove(player) notice(nick, combine_cards(round_call_card, [player.hand[i] for i in selected_cards])) elif event == events.cards: nick, = args if nick not in players: # Ignore those not in the game continue player = players[nick] if player in choosers or player in card_choices: send_cards(nick) else: notice(nick, 'You can\'t choose now') else: r = common_handler(event, args) if r is not None: return r return bottom_of_round def bottom_of_round(): nonlocal players, round_call_card, czar, card_choices send('Everyone has chosen. %s, now\'s your time to choose.' % czar.nick) # Display the cards choosers = random.sample(card_choices.keys(), k = len(card_choices)) for index, player in enumerate(choosers): send('%i: %s' % (index, combine_cards(round_call_card, [player.hand[i] for i in card_choices[player]]))) while True: if len(players) < 2: send('Not enough players to continue, quiting game') if czar not in players.values(): send('Czar left the game, restarting round') return setup_round event, *args = get_event() if event == events.status: send('Waiting for czar %s to choose' % czar.nick) elif event == events.start: send('Game already in progress') elif event == events.card: nick, choices = args # Ignore those not in the game if nick not in players: continue player = players[nick] if player is not czar: notice(nick, 'Only the czar can choose now') continue if len(choices) != 1: notice(nick, 'Select one choice') continue choice = choices[0] if 0 <= choice < len(choosers): player = choosers[choice] player.points += 1 # Winner is Czar semantics czar = player send('The winner is %s with: %s' % (player.nick, combine_cards(round_call_card, [player.hand[i] for i in card_choices[player]]))) break else: notice(nick, '%i not in range' % choice) else: r = common_handler(event, args) if r is not None: return r points = [] for player in players.values(): if player in choosers: points.append('%s: %i (%i)' % (player.nick, player.points, choosers.index(player))) else: points.append('%s: %i' % (player.nick, player.points)) send('Points: %s' % ' | '.join(points)) return teardown_round def teardown_round(): nonlocal players, limit, round_number, round_call_card, card_choices if limit.type == limit_types.rounds: if round_number >= limit.number: return end_game elif limit.type == limit_types.points: if max(i.points for i in players.values()) >= limit.number: return end_game # Remove the cards that were played this round from hands for player in card_choices: for index in card_choices[player]: player.hand[index] = None # Increase the number of the round and clear the call card # These are not done in setup_round() since we might want to # restart a round in case the czar leaves round_number += 1 round_call_card = None return setup_round def end_game(): nonlocal players max_score = max(i.points for i in players.values()) winners = [i for i in players.values() if i.points == max_score] send('We have a winner! %s' % ', '.join(i.nick for i in winners)) return no_game def quit(): pass players = None decks = None limit = None round_number = None round_call_card = None czar = None card_choices = None cardcast_deck_count = None state = no_game while state != quit: state = state() if __name__ == '__main__': def get_event(): while True: try: t = input('> ') except EOFError: return (events.quit,) if t == 'nick': old = input('old> ') new = input('new> ') return (events.nick_change, old, new) elif t == 'start': nick = input('nick> ') return (events.start, nick) elif t == 'ready': nick = input('nick> ') return (events.ready, nick) elif t == 'unready': nick = input('nick> ') return (events.unready, nick) elif t == 'status': return (events.status,) elif t == 'kill': return (events.kill,) elif t == 'join': nick = input('nick> ') return (events.join, nick) elif t == 'leave': nick = input('nick> ') return (events.leave, nick) elif t == 'players': return (events.players,) elif t == 'deck add': code = input('code> ') return (events.deck_add, code) elif t == 'deck add random': return (events.deck_add_random,) elif t == 'deck remove': code = input('code> ') return (events.deck_remove, code) elif t == 'deck list': return (events.deck_list,) elif t == 'limit': return (events.limit,) elif t == 'limit_set': limit_type = {'r': limit_types.rounds, 'p': limit_types.points}[input('type (p/r)> ')] number = int(input('limit> ')) return (events.limit, limit_type, number) elif t == 'card': nick = input('nick> ') choice = [int(i) for i in input('choice> ').split()] return (events.card, nick, choice) elif t == 'cards': nick = input('nick> ') return (events.cards, nick) else: print('?') def send(text): print(text) def notice(nick, text): print('\t', nick, text) def nop(*args, **kwargs): pass game(send, notice, nop, nop, get_event)