diff --git a/cardcast_api.py b/cardcast_api.py new file mode 100644 index 0000000..c58ae88 --- /dev/null +++ b/cardcast_api.py @@ -0,0 +1,57 @@ +import json +import random +import urllib.request + +base_url = 'https://api.cardcastgame.com/v1/decks/' + +class CodeValidationError(Exception): pass + +def check_code(code): + def accepted_character(char): + # Upper case letters and numbers are allowed + return ord('A') <= ord(char) <= ord('Z') or ord('0') <= ord(char) <= ord('9') + + if len(code) != 5: + raise CodeValidationError('Code %s not 5 characters long' % code) + elif any(not accepted_character(char) for char in code): + raise CodeValidationError('Code %s contains disallowed characters (0-9 and A-Z are allowed' % code) + +def request(additional, timeout): + url = base_url + additional + with urllib.request.urlopen(url, timeout = timeout) as r: + return json.loads(r.read()) + +def info(code, *, timeout = 10): + check_code(code) + return request(code, timeout) + +def cards(code, *, timeout = 10): + check_code(code) + + data = request(code + '/cards', timeout) + + calls = [i['text'] for i in data['calls']] + responses = [i['text'] for i in data['responses']] + + # Both calls and responses are stored as a list of parts + # For calls, the blanks go between the parts so that ['foo', 'bar'] is + # "foo _ bar", but for resposes there is never more than one part + # Therefore, remove the additional layer of list + responses = [i for (i,) in responses] + + return (calls, responses) + +def random_code(*, timeout = 10, count = None): + + if count is None: + # Get number of cards + results = request('?category=&direction=desc&limit=1&sort=rating&offset=0', timeout) + count = results['results']['count'] + + # Generate a random number [0, count[ + offset = random.randint(0, count - 1) + + # Get the data on the selected deck + results = request('?category=&direction=desc&limit=1&sort=rating&offset=%i' % offset, timeout) + + return (results['results']['data'][0]['code'], count) diff --git a/dump_deck.py b/dump_deck.py new file mode 100644 index 0000000..89158ed --- /dev/null +++ b/dump_deck.py @@ -0,0 +1,29 @@ +import sys + +import cardcast_api + +def main(): + code = sys.argv[1] + info = cardcast_api.info(code) + print('%s: %s by %s (%s black, %s white)' % ( + info['code'], + info['name'], + info['author']['username'], + info['call_count'], + info['response_count'] + )) + + print() + + calls, responses = cardcast_api.cards(code) + + for i in calls: + print('_'.join(i)) + + print() + + for i in responses: + print(i) + +if __name__ == '__main__': + main() diff --git a/gameloop.py b/gameloop.py new file mode 100644 index 0000000..b72cd17 --- /dev/null +++ b/gameloop.py @@ -0,0 +1,639 @@ +import enum +import random +from collections import namedtuple + +import cardcast_api + +class events(enum.Enum): + quit, nick_change, status, start, kill, join, leave, players, deck_add, deck_add_random, deck_remove, deck_list, limit, card = range(14) + +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) + +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) + if t == 'start': + nick = input('nick> ') + return (events.start, 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) + else: + print('?') + +class Error: pass + +def game(): + def send(text): + print(text) + + def notice(nick, text): + print('\t', nick, text) + + 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) + + # 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 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 + change_player_nick(old, new) + + elif event == events.join: + nick, = args + if nick not in players: + add_player(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: + send('%s has left the game' % nick) + + elif event == events.players: + list_players() + + elif event == events.deck_add: + code, = args + if code not in decks: + errwrapper('Failure adding deck: %s (%%s)' % code, add_deck, code) + else: + send('Deck already added') + + elif event == events.deck_add_random: + # 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) + + 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: + # Ignore selecting cards if it's not available + pass + + else: + error('Unknown event type: %s' % event) + + def no_game(): + nonlocal players, decks, limit, round_number, round_call_card, czar, card_choices + 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, = args + add_player(nick) + + send('%s started a game, !join to join!' % nick) + + return game_setup + + elif event == events.quit: + return quit + + else: + send('Start with !start') + + def game_setup(): + nonlocal players + + while True: + if len(players) == 0: + send('Lost all players, quiting game setup') + return no_game + + event, *args = get_event() + + if event == events.status: + send('Game setup') + + elif event == events.start: + if len(players) < 2: + send('Not enough players') + else: + return setup_round + + else: + r = common_handler(event, args) + if r is not None: return r + + 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 + + # 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(): + 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(): + 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() + + # Select a czar randomly, if we need to + if czar not in players.values(): + czar = random.choice(list(players.values())) + + 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): + combined = [sanitize(call[0])] + + for i in range(len(call) - 1): + combined.append('[' + sanitize(responses[i]) + ']') + combined.append(sanitize(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: + notice(nick, 'You\'ll get to choose next round') + continue + + selected_cards = [] + for choice in choices: + if 0 <= choice < len(player.hand): + selected_cards.append(choice) + else: + notice(nick, '%i not in your hand' % choice) + break + + if len(selected_cards) != len(choices): + # Failed to use some choice + 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(selected_cards) != len(round_call_card) - 1: + notice(nick, 'Select %i card(s)' % (len(round_call_card) - 1)) + continue + + card_choices[player] = selected_cards + choosers.remove(player) + notice(nick, combine_cards(round_call_card, [player.hand[i] for i in selected_cards])) + + 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() + + send('Quiting') + +if __name__ == '__main__': + game() diff --git a/random_deck.py b/random_deck.py new file mode 100644 index 0000000..8996140 --- /dev/null +++ b/random_deck.py @@ -0,0 +1,18 @@ +import sys + +import cardcast_api + +def main(): + code, _ = cardcast_api.random_code() + + info = cardcast_api.info(code) + print('%s: %s by %s (%s black, %s white)' % ( + info['code'], + info['name'], + info['author']['username'], + info['call_count'], + info['response_count'] + )) + +if __name__ == '__main__': + main()