import enum import random from collections import namedtuple import cardcast_api class events(enum.Enum): (quit, nick_change, status, start, ready, unready, kill, join, leave, players, kick, deck_add, deck_add_random, deck_remove, deck_list, bot_add_rando, bot_remove, limit, card, cards, origins) = range(21) 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']) Card = namedtuple('Card', ['deck', 'text']) 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 Rando: def __init__(self, name): self.nick = '<%s>' % name self.hand = [] self.points = 0 def num_need_cards(self, num_blanks): return num_blanks - len(self.hand) + self.hand.count(None) def give_cards(self, cards): self.hand.extend(cards) self.hand = [i for i in self.hand if i is not None] def play(self, num_blanks): return list(range(num_blanks)) 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 players_bots(): nonlocal players, bots yield from players.values() yield from bots.values() 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, bots send(', '.join(sorted(players) + sorted(i.nick for i in bots.values()))) def add_deck(code): nonlocal decks assert code not in decks # Colondeck lives elsewhere if code == 'colondeck': base_url = 'https://dl.puckipedia.com/' else: base_url = None # First get info for the deck we're adding info = cardcast_api.info(code, base_url = base_url) # Extract the information we want to keep of the deck name = info['name'] author = info['author']['username'] # Get cards calls, responses = cardcast_api.cards(code, base_url = base_url) call_count = len(calls) response_count = len(responses) # 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, round_call_card # Purge all the cards from the deck from the game for player_bot in players_bots(): for index, card in enumerate(player_bot.hand): if card is not None and card.deck.code == code: player_bot.hand[index] = None if round_call_card is not None and round_call_card.deck.code == code: round_call_card = None 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) send('Added deck %s (%s)' % (decks[code].name, code)) def get_hand_origins(player): hand_origins = [] for card in player.hand: if card is None: hand_origins.append('') else: hand_origins.append(card.deck.code) return ', '.join('%i: %s' % (index, i) for index, i in enumerate(hand_origins)) def common_handler(event, args): nonlocal players, bots, 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.kick: kicker, kickee = args if kicker not in players: # Ignore those not in the game pass elif kickee not in players: send('No such player %s' % kickee) elif errwrapper('Could not remove player %s (%%s)' % kickee, remove_player, kickee) is not Error: devoice(kickee) send('%s has been remove from the game' % kickee) 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.bot_add_rando: name, = args if name not in bots: bots[name] = Rando(name) else: send('Bot named %s already exists' % name) elif event == events.bot_remove: name, = args if name in bots: del bots[name] else: send('No such bot %s' % name) 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.origins: nick, = args if nick in players: origins = get_hand_origins(players[nick]) if origins != '': notice(nick, origins) 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, bots, decks, limit, round_number, round_call_card, czar, card_choices if players is not None: devoice(players) players = {} bots = {} 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), :Deck (colondeck) and three random decks') deck_add_handler('A5DCM') deck_add_handler('PXWKC') deck_add_handler('colondeck') 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: pass 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_ready = set(i for i in players_ready if i in players.values()) 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: if len(args) == 1: break else: send('Can\'t apply presets once the game setup has started. Here !start begins the game without waiting for !ready') 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 Card(deck, 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(Card(deck, 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, bots, 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) # See note above num_blanks in top_of_round() num_blanks = len(round_call_card.text) - 1 for bot in bots.values(): need_responses += bot.num_need_cards(num_blanks) # 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() # Give cards to bots for bot in bots.values(): needed = bot.num_need_cards(num_blanks) fed = responses[:needed] responses = responses[needed:] bot.give_cards(fed) 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.text)) 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 combine_played(call, player_bot, selected_cards): return combine_cards(call.text, [player_bot.hand[i].text for i in selected_cards]) def top_of_round(): nonlocal players, bots, round_number, round_call_card, czar, card_choices choosers = [i for i in players.values() if i is not czar] send('Round %i. %s is czar. %s choose your cards' % (round_number, czar.nick, ', '.join(i.nick for i in choosers))) send('[%s]' % '_'.join(sanitize(part) for part in round_call_card.text)) # Round call card has N parts. Between each of those parts # goes one response card. Therefore there should be N - 1 # response cards num_blanks = len(round_call_card.text) - 1 # Have bots choose first for bot in bots.values(): card_choices[bot] = bot.play(num_blanks) 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') return no_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 if len(choices) != num_blanks: notice(nick, 'Select %i card(s)' % (len(round_call_card.text) - 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_played(round_call_card, player, 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') elif event == events.origins: nick, = args if nick not in players: notice(nick, 'call: %s' % round_call_card.deck.code) else: notice(nick, 'call: %s, %s' % (round_call_card.deck.code, get_hand_origins(players[nick]))) elif event == events.deck_remove: common_handler(event, args) # Did we lose our call card? if round_call_card is None: # Yes, restart round send('Lost the black card, restarting round') return setup_round # Did it remove a card from someone voting this round? for player in choosers: if None in player.hand: # Yes, restart round send('Lost a card from player\'s hand, restarting round') return setup_round for player_bot in card_choices: # We are checking all cards here, not # just the ones chosen. This is because # a player may change their selection, # in which case we might hit a None if None in player_bot.hand: # Yes, restart round send('Lost a card from player\'s hand, restarting round') return setup_round 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_bot in enumerate(choosers): send('%i: %s' % (index, combine_played(round_call_card, player_bot, card_choices[player_bot]))) while True: if len(players) < 2: send('Not enough players to continue, quiting game') return no_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: choice = choices[0] if 0 <= choice < len(choosers): player_bot = choosers[choice] player_bot.points += 1 # Winner is Czar semantics if a # player won, random otherwise if player_bot in players.values(): czar = player_bot else: czar = None send('The winner is %s with: %s' % (player_bot.nick, combine_played(round_call_card, player_bot, card_choices[player_bot]))) break else: notice(nick, '%i not in range' % choice) elif len(choices) == 0: # Special case: award everyone a point # and randomize czar for player_bot in card_choices: player_bot.points += 1 # If we set czar to None, setup_round() # will handle ramdomizing it for us czar = None send('Everyone is a winner!') break else: notice(nick, 'Select one or zero choices') elif event == events.origins: nick, = args if nick not in players: notice(nick, 'call: %s' % round_call_card.deck.code) else: answers_origins = [] for index, player_bot in enumerate(choosers): answer_origins = [player_bot.hand[i].deck.code for i in card_choices[player_bot]] answers_origins.append('%i: %s' % (index, ', '.join(answer_origins))) notice(nick, 'call: %s; %s' % (round_call_card.deck.code, '; '.join(answers_origins))) elif event == events.deck_remove: common_handler(event, args) # Did we lose our call card? if round_call_card is None: # Yes, restart round send('Lost the black card, restarting round') return setup_round # Did it affect any response cards on this round? for player_bot in card_choices: for index in card_choices[player_bot]: if player_bot.hand[index] is None: # Yes, restart round send('Lost a card played this round, restarting round') return setup_round else: r = common_handler(event, args) if r is not None: return r points = [] for player_bot in players_bots(): if player_bot in choosers: points.append('%s: %i (%i)' % (player_bot.nick, player_bot.points, choosers.index(player_bot))) else: points.append('%s: %i' % (player_bot.nick, player_bot.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_bots()) >= limit.number: return end_game # Remove the cards that were played this round from hands for player_bot in card_choices: for index in card_choices[player_bot]: player_bot.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_bots()) winners = [i for i in players_bots() 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 bots = 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 == 'kick': kicker = input('kicker> ') kickee = input('kickee> ') return (events.kick, kicker, kickee) 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 == 'bot add rando': name = input('name> ') return (events.bot_add_rando, name) elif t == 'bot remove': name = input('name> ') return (events.bot_remove, name) 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) elif t == 'origins': nick = input('nick> ') return (events.origins, 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)