1
0
Fork 0

First commit (of the CaH bot)

This commit is contained in:
Juhani Krekelä 2019-05-05 23:39:50 +03:00
parent baaa93d2fa
commit f4795934d3
4 changed files with 743 additions and 0 deletions

57
cardcast_api.py Normal file
View File

@ -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)

29
dump_deck.py Normal file
View File

@ -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()

639
gameloop.py Normal file
View File

@ -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()

18
random_deck.py Normal file
View File

@ -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()