A simple Cards Against Humanity bot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1437 lines
39 KiB

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, redeal) = range(22)
class limit_types(enum.Enum):
points, rounds = range(2)
# fqcode = fully qualified code = (namespace, code)
Deck = namedtuple('Deck', ['fqcode', 'name', 'author', 'call_count', 'response_count', 'calls', 'responses'])
Limit = namedtuple('Limit', ['type', 'number'])
Card = namedtuple('Card', ['deck', 'text'])
Namespace = namedtuple('Namespace', ['url', 'supports_random'])
deck_namespaces = {
'bslsk05': Namespace('https://dl.puckipedia.com/', False),
'colondeck': Namespace('https://puck.moe/_/cards/', False),
'ahti': Namespace('https://ahti.space/cards/', True)
}
class IRCFormattingState:
def __init__(self):
# 99 is the "client default colour"
self.fg_color = 99
self.bg_color = 99
self.bold = False
self.italic = False
self.underline = False
self.reverse = False
def __eq__(self, other):
return (
self.fg_color == other.fg_color and
self.bg_color == other.bg_color and
self.bold == other.bold and
self.italic == other.italic and
self.underline == other.underline and
self.reverse == other.reverse
)
def copy(self):
new = IRCFormattingState()
new.fg_color = self.fg_color
new.bg_color = self.bg_color
new.bold = self.bold
new.italic = self.italic
new.underline = self.underline
new.reverse = self.reverse
return new
class Player:
def __init__(self, nick):
self.nick = nick
self.hand = []
self.points = 0
self.message = None
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
self.message = None
def num_need_cards(self, num_blanks):
return max(num_blanks - len(self.hand) + self.hand.count(None), 0)
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 self.hand[: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(namespace, code):
nonlocal decks
assert (namespace, code) not in decks
base_url = deck_namespaces[namespace].url
# 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
# Preprocess calls so that they are cut short if they're >200 chars
for i in range(len(calls)):
call = []
combined_length = 0
for index, part in enumerate(calls[i]):
if combined_length + len(part) > 200:
part = part[:200 - combined_length] + ''
call.append(part)
combined_length += len(part) + 1
calls[i] = call
# Preprocess responses so that they are at max. 160 chars
for i in range(len(responses)):
if len(responses[i]) > 160:
responses[i] = responses[i][:159] + ''
# Add a new deck to list of decks
decks[(namespace, code)] = Deck(
fqcode = (namespace, code),
name = name,
author = author,
call_count = call_count,
response_count = response_count,
calls = calls,
responses = responses
)
def get_random_deck_code(namespace):
nonlocal remote_deck_count
base_url = deck_namespaces[namespace].url
# Keep track of how many cards there are on the remote, so that we don't
# need to keep rerequesting that
if namespace not in remote_deck_count:
code, remote_deck_count[namespace] = cardcast_api.random_code(base_url = base_url)
else:
code, remote_deck_count[namespace] = cardcast_api.random_code(count = remote_deck_count[namespace], base_url = base_url)
return code
def remove_deck(namespace, 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.fqcode == (namespace, code):
player_bot.hand[index] = None
if round_call_card is not None and round_call_card.deck.fqcode == (namespace, code):
round_call_card = None
del decks[(namespace, 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)
namespace, code = deck.fqcode
send('%s (%s %s, by %s, %s black, %s white)' % (
deck.name,
namespace,
code,
deck.author,
calls,
responses
))
def deck_add_handler(namespace, code):
nonlocal decks
if namespace in deck_namespaces:
if (namespace, code) not in decks:
errwrapper('Failure adding deck: %s %s (%%s)' % (namespace, code), add_deck, namespace, code)
else:
send('Deck already added')
else:
send('Unknown deck namespace %s. Try one of: %s' % (namespace, ', '.join(deck_namespaces.keys())))
def deck_add_random_handler(namespace):
nonlocal decks
if namespace in deck_namespaces:
if deck_namespaces[namespace].supports_random:
for _ in range(5):
code = errwrapper('Failure getting random code for a deck. (%s)', get_random_deck_code, namespace)
if code is Error: return
if (namespace, code) not in decks: break
send('That was weird, got %s randomly but it was already added' % code)
else:
send('Did not get a fresh random deck in 5 tries, bailing out')
return
errwrapper('Failure adding deck: %s %s (%%s)' % (namespace, code), add_deck, namespace, code)
send('Added deck %s (%s %s)' % (decks[(namespace, code)].name, namespace, code))
else:
send('Namespace %s does\'t support adding a random deck. Try one of: %s' % (namespace, ', '.join(namespace for namespace in deck_namespaces.keys() if deck_namespaces[namespace].supports_random)))
else:
send('Unknown deck namespace %s. Try one of: %s' % (namespace, ', '.join(deck_namespaces.keys())))
def get_hand_origins(player):
hand_origins = []
for card in player.hand:
if card is None:
hand_origins.append('<empty>')
else:
hand_origins.append('%s %s' % card.deck.fqcode)
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:
if len(args) == 2:
nick, message = args
if nick not in players:
add_player(nick)
voice(nick)
players[nick].message = message
send('%s has joined %s' % (nick, message))
else:
nick, = args
if nick not in players:
add_player(nick)
voice(nick)
players[nick].message = None
send('%s has joined' % nick)
elif event == events.leave:
nick, = args
if nick not in players:
# Ignore those not in the game
pass
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 removed from the game' % kickee)
elif event == events.deck_add:
namespace, code = args
deck_add_handler(namespace, code)
elif event == events.deck_add_random:
namespace, = args
deck_add_random_handler(namespace)
elif event == events.deck_remove:
namespace, code = args
if (namespace, code) in decks:
errwrapper('Failure removing deck %s (%%s)' % code, remove_deck, namespace, code)
else:
send('No such deck %s %s' % (namespace, 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)
send('Bot %s added' % name)
else:
send('Bot named %s already exists' % name)
elif event == events.bot_remove:
name, = args
if name in bots:
del bots[name]
send('Bot %s removed' % 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 or event == events.redeal:
# Ignore card commands if no cards are available yet
pass
elif event == events.ready or event == events.unready:
# Ignore readiness commands by default
pass
else:
error('Unknown event type: %s' % event)
def start_game(rest):
if len(rest) == 0 or rest[0] == 'default':
send('Adding the default CAH deck (ahti Base)')
deck_add_handler('ahti', 'Base')
elif rest[0] == 'offtopia-random':
send('Adding the default CAH deck (ahti Base), offtopia injoke deck (bslsk05 offtopiadeck), :Deck (bslsk05 colondeck) and three random ahti decks')
deck_add_handler('ahti', 'Base')
deck_add_handler('bslsk05', 'offtopiadeck')
deck_add_handler('colondeck', 'colondeck')
for _ in range(3):
deck_add_random_handler('ahti')
elif rest[0] == 'offtopia':
send('Adding the default CAH deck (ahti Base), offtopia injoke deck (bslsk05 offtopiadeck), and :Deck (bslsk05 colondeck)')
deck_add_handler('ahti', 'Base')
deck_add_handler('bslsk05', 'offtopiadeck')
deck_add_handler('colondeck', 'colondeck')
elif rest[0] != 'empty':
send('Unknown preset %s' % rest[0])
return False
return True
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)
if start_game(rest):
limit_type = {limit_types.rounds: 'rounds', limit_types.points: 'points'}[limit.type]
send('Limit is %i %s, change with !limit' % (limit.number, limit_type))
send('Once you are ready to start the game, everyone send !ready')
return game_setup
else:
send('Stopping game')
# If we don't do this, the partially started game state doesn't get cleared off correctly
return no_game
elif event == events.join:
send('Start a game with !start [<preset>]')
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, quitting 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 (needs at least two joined). Try inviting others and send !ready again once they\'ve joined.')
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()
# See note above num_blanks in top_of_round()
num_blanks = len(round_call_card.text) - 1
# Find out how many response cards we need
hand_size = 9 + num_blanks
need_responses = 0
for player in players.values():
# Don't deal cards to the czar this round
if player is czar: continue
need_responses += max(hand_size - len(player.hand) + player.hand.count(None), 0)
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
# Move the cards outside of the current hand size into
# the hand
overflow = [i for i in player.hand[hand_size:] if i is not None]
player.hand = player.hand[:hand_size]
for index in range(len(player.hand)):
if len(overflow) == 0:
break
if player.hand[index] is None:
# .pop(0) instead of .pop() since we
# want to keep the same order
player.hand[index] = overflow.pop(0)
# Do we still have some overflow cards we couldn't fit
# into the hand? If so, just stick them at the end and
# we'll just have an oversized hand this round
player.hand.extend(overflow)
# Fill any remaining empty spots with dealt cards
while len(player.hand) < hand_size:
player.hand.append(responses.pop())
for index in range(hand_size):
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 handle_control_codes(text, start_state):
state = start_state.copy()
r = []
index = 0
while index < len(text):
char = text[index]
index += 1
if char == '\x02':
# ^B - bold
state.bold = not state.bold
r.append(char)
elif char == '\x1d':
# ^] - italic
state.italic = not state.italic
r.append(char)
elif char == '\x1f':
# ^_ - underline
state.underline = not state.underline
r.append(char)
elif char == '\x16':
# ^V - reverse video mode
state.reverse = not state.reverse
r.append(char)
elif char == '\x0f':
# ^O - disable all formatting
state = IRCFormattingState()
r.append(char)
elif char == '\x03':
# ^C - colour
start = index - 1
# Find the foreground colour
# It can be at max 2 digits forwards
# We use <= here since we are comparing against
# the end point of slice, which'll be one more
# than the index of the last cell in the slice
if index + 2 <= len(text) and text[index:index + 2].isdigit():
state.fg_color = int(text[index:index + 2])
index += 2
elif index + 1 <= len(text) and text[index:index + 1].isdigit():
state.fg_color = int(text[index:index +1])
index += 1
else:
# Not a valid colour code after all
r.append('^C')
continue
r.append(text[start:index])
# Do we have a background colour?
if index < len(text) and text[index] == ',':
# Maybe
start = index
index += 1
# Find the bg colour
# Details are the same as above
if index + 2 <= len(text) and text[index:index + 2].isdigit():
state.bg_color = int(text[index:index + 2])
index += 2
elif index + 1 <= len(text) and text[index:index + 1].isdigit():
state.bg_color = int(text[index:index +1])
index += 1
else:
# No bg colour after all
index -= 1
r.append(text[start:index])
elif ord(char) < 32 or ord(char) == 127:
r.append('^' + chr(ord(i) ^ 64))
else:
r.append(char)
return (''.join(r), state)
def to_formatting_state(from_state, to_state):
if to_state == from_state:
return ''
elif to_state == IRCFormattingState():
return '\x0f' # ^O
r = ''
if to_state.fg_color != from_state.fg_color or to_state.bg_color != from_state.bg_color:
# Always use the full form, if for no other reason than
# to not screw up ,<num> or <num> following this
r += '\x03%02i,%02i' % (to_state.fg_color, to_state.bg_color)
if to_state.bold != from_state.bold:
r += '\x02' # ^B
if to_state.italic != from_state.italic:
r += '\x1d' # ^]
if to_state.underline != from_state.underline:
r += '\x1f' # ^_
if to_state.reverse != from_state.reverse:
r += '\x16' # ^V
return r
def sanitize(text, start_state):
sanitized, state_after = handle_control_codes(text, start_state)
return sanitized + to_formatting_state(state_after, start_state)
def send_cards(nick):
nonlocal players
no_formatting = IRCFormattingState()
cards = ' | '.join('%i: [%s]' % (index, sanitize(card.text, no_formatting)) for index, card in enumerate(players[nick].hand))
notice(nick, cards)
def combine_cards(call, responses, dereference_responses = True):
# This function is really messy, in part due to the format used
#
# `call` is a Card object, with `call.text` being a list of
# strings, and here I'll refer to each of those strings as
# "part". This division into parts indicates the locations of
# the blanks. For example:
#
# Foo bar? _.
#
# is stored as
#
# ['Foo bar? ', '.']
#
# So far good, take a part from `call.text`, then one response
# (assuming we still have one remaining) and repeat.
#
# However, CardsAgainstIRC extended the simple system of blanks
# with backreferences that are of the form /\$[0-9]/. Since
# Cardcast doesn't know about them, they are stored like any
# other text. E.g.
#
# Foo _? Bar $0.
# ['Foo ', '? Bar $0.']
#
# You could add backreference support to the earlier algorithm
# by going over each part and replacing each $<num> with the
# corresponding response when you add the call part. It'd give
# this algorith:
#
# 1. Go over call.text[index] and satisfy backrefrences
# 1. Copy text verbatim until we hit a $<num>
# 2. Add responses[num].text
# 3. Repeat
# 2. Add response[index].text
# 3. Repeat
#
# This was how I first implemented it. However, dealing with
# backreferences has more to do with dealing a blank than it
# does with copying the rest of the part For this reason, this
# function deals # with the concept of a "segment". Where parts
# are delineated by blanks, segments are delineated by both
# blanks and backreferences. To reuse the example from earlier:
#
# Foo _? Bar $0.
# ^^^^ ^^^^^^ ^
# 1111 222222 3
#
# Because the storage format is still split by part instead of
# by segment, this function has to manually split each part into
# its segments. That's why we walk both part by part and char by
# char.
#
# Our algorithm is
# 1. Find either a blank, end of card, or a backreference,
# whichever comes first
# blank:
# 1. Copy the segment before this verbatim
# 2. Add next response
# end:
# 1. Copy the segment before this verbatim
# backreference:
# 1. Copy the segment before this verbatim
# 2. Add the response pointed to by the
# backreference
# 2. Repeat
#
# Additionally, we have a special mode in case we are displaying
# the round call card, where we do not look up the responses
# pointed to by backreferences or blanks, but instead add the
# $<num> or _. In this mode `responses` still has to be correct
# length in order to recognize valid backreferences and to
# distinguish between a blank and end of a card (this could
# admitedly use some work).
formatting_state = IRCFormattingState()
no_formatting = IRCFormattingState()
r = []
part_index = 0
index = 0
segment_start_index = 0
while part_index < len(call.text):
call_part = call.text[part_index]
if index >= len(call_part):
# We've reached a blank or the end of the card
# Copy the previous segment fully to `r`
call_segment, formatting_state = handle_control_codes(call_part[segment_start_index:], formatting_state)
r.append(call_segment)
# If there is still one, add the response coming
# after that segment
if part_index < len(responses):
if dereference_responses:
# Add response
r.append(to_formatting_state(formatting_state, no_formatting))
r.append('[')
r.append(sanitize(responses[part_index].text, no_formatting))
r.append(']')
r.append(to_formatting_state(no_formatting, formatting_state))
else:
# Add the blank itself (useful for displaying the call card)
r.append(to_formatting_state(formatting_state, no_formatting))
r.append('_')
r.append(to_formatting_state(no_formatting, formatting_state))
# Start on a new part as well as a new segment
part_index += 1
index = 0
segment_start_index = 0
continue
char = call_part[index]
index += 1
if char == '$':
if index < len(call_part) and ord('0') <= ord(call_part[index]) <= ord('9'):
# Handle $0 .. $9
# Hopefully we won't run into more
# backreferences in one card
backreference_index = int(call_part[index])
index += 1
if 0 <= backreference_index < len(responses):
# Copy the previous segment fully to `r`
call_segment, formatting_state = handle_control_codes(call_part[segment_start_index:index - 2], formatting_state)
r.append(call_segment)
if dereference_responses:
# Add the response this backreference refers to
r.append(to_formatting_state(formatting_state, no_formatting))
r.append('[')
r.append(sanitize(responses[backreference_index].text, no_formatting))
r.append(']')
r.append(to_formatting_state(no_formatting, formatting_state))
else:
# Add the backreference itself (useful for displaying the call card)
r.append(to_formatting_state(formatting_state, no_formatting))
r.append('$%i' % backreference_index)
r.append(to_formatting_state(no_formatting, formatting_state))
# Start new segment after this char
segment_start_index = index
else:
# A backreference, but not a
# valid one. Copy verbatim
pass
else:
# Not a backreference
pass
r.append(to_formatting_state(formatting_state, no_formatting))
return ''.join(r)
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]
# 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
send('Round %i. %s is czar. %s choose your cards' % (round_number, czar.nick, ', '.join(i.nick for i in choosers)))
send('[%s]' % combine_cards(round_call_card, [None] * num_blanks, dereference_responses = False))
# 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 True:
# 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(choosers) == 0:
break
if len(players) < 2:
send('Not enough players to continue (needs at least two), quitting 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):
selected_cards.append(player.hand[choice])
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, 'You have chosen: ' + combine_cards(round_call_card, 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 %s ' % round_call_card.deck.fqcode)
else:
notice(nick, 'call: %s %s, %s' % (*round_call_card.deck.fqcode, get_hand_origins(players[nick])))
elif event == events.redeal:
nick, = args
if nick not in players:
# Ignore those not in the game
continue
player = players[nick]
for index in range(len(player.hand)):
player.hand[index] = None
if player in choosers or player in card_choices:
send('Dealing out a new hand to %s, restarting round' % nick)
return setup_round
else:
notice(nick, 'New hand will be dealt next round')
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_cards(round_call_card, card_choices[player_bot])))
while True:
if len(players) < 2:
send('Not enough players to continue (needs at least two), quitting 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 who's still in the game
# won, keep same czar if a bot
# won, randomize otherwise
if player_bot in players.values():
czar = player_bot
elif player_bot in bots.values():
pass
else:
# setup_round() will
# randomize
czar = None
send('The winner is %s with: %s' % (player_bot.nick, combine_cards(round_call_card, 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 %s' % round_call_card.deck.fqcode)
else:
answers_origins = []
for index, player_bot in enumerate(choosers):
answer_origins = ['%s %s' % i.deck.fqcode for i in card_choices[player_bot]]
answers_origins.append('%i: %s' % (index, ', '.join(answer_origins)))
notice(nick, 'call: %s %s; %s' % (*round_call_card.deck.fqcode, '; '.join(answers_origins)))
elif event == events.redeal:
nick, = args
if nick not in players:
# Ignore those not in the game
continue
player = players[nick]
for index in range(len(player.hand)):
player.hand[index] = None
notice(nick, 'New hand will be dealt next round')
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
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:
played = card_choices[player_bot]
for card in card_choices[player_bot]:
for index, hand_card in enumerate(player_bot.hand):
if hand_card is card:
player_bot.hand[index] = None
break
# 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]
if len(winners) == 1:
winner, = winners
if winner.message is not None:
send('We have a winner! %s won %s' % (winner.nick, winner.message))
else:
send('We have a winner! %s' % winner.nick)
else:
send('We have the winners! %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
remote_deck_count = {}
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 == 'start_preset':
nick = input('nick> ')
preset = input('preset> ')
return (events.start, nick, preset)
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 == 'join_message':
nick = input('nick> ')
message = input('message> ')
return (events.join, nick, message)
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':
namespace = input('namespace> ')
code = input('code> ')
return (events.deck_add, namespace, code)
elif t == 'deck add random':
namespace = input('namespace> ')
return (events.deck_add_random, namespace)
elif t == 'deck remove':
namespace = input('namespace> ')
code = input('code> ')
return (events.deck_remove, namespace, 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)
elif t == 'redeal':
nick = input('nick> ')
return (events.redeal, 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)