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