From c5a58dac4ee46a7adb7dcb95ba1f258653557b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhani=20Krekel=C3=A4?= Date: Wed, 15 May 2019 20:50:02 +0300 Subject: [PATCH] Handle IRC formatting --- gameloop.py | 314 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 275 insertions(+), 39 deletions(-) diff --git a/gameloop.py b/gameloop.py index 3063831..b462f4a 100644 --- a/gameloop.py +++ b/gameloop.py @@ -22,6 +22,36 @@ Limit = namedtuple('Limit', ['type', 'number']) Card = namedtuple('Card', ['deck', 'text']) +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 @@ -659,58 +689,264 @@ def game(send, notice, voice, devoice, get_event): 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 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 , or 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 - cards = ' | '.join('%i: [%s]' % (index, sanitize(card.text)) for index, card in enumerate(players[nick].hand)) + 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): - def handle_call_part(call_part): - nonlocal responses + # This function is really messy, in part due to the format used + # + # `call` is a list of strings, and here I'll refer to each of + # them 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`, 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 $ with the + # corresponding response when you add the call part. It'd give + # this algorith: + # + # 1. Go over call[index] and satisfy backrefrences + # 1. Copy text verbatim until we hit a $ + # 2. Add responses[num] + # 3. Repeat + # 2. Add response[index] + # 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 - r = [] - after_dollar = False - for char in call_part: - if after_dollar and ord('0') <= ord(char) <= ord('9'): + formatting_state = IRCFormattingState() + no_formatting = IRCFormattingState() + + r = [] + part_index = 0 + index = 0 + segment_start_index = 0 + while part_index < len(call): + call_part = call[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): + r.append(to_formatting_state(formatting_state, no_formatting)) + r.append('[') + r.append(sanitize(responses[part_index], 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 - index = int(char) + # 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) + + # Add the response this backreference refers to + r.append(call_segment) + r.append(to_formatting_state(formatting_state, no_formatting)) + r.append('[') + r.append(sanitize(responses[backreference_index], no_formatting)) + r.append(']') + r.append(to_formatting_state(no_formatting, formatting_state)) + + # Start new segment after this char + segment_start_index = index - if 0 <= index < len(responses): - r.append('[' + responses[index] + ']') else: - # Not valid backreference, copy verbatim - r.append('$' + char) - - after_dollar = False - - elif after_dollar: - # Wasn't a backreference, copy verbatim - r.append('$' + char) - - after_dollar = False - - elif char == '$': - after_dollar = True + # A backreference, but not a + # valid one. Copy verbatim + r.append('$') + r.append(call_part[index]) else: - r.append(char) + # Not a backreference + r.append('$') - return sanitize(''.join(r)) + r.append(to_formatting_state(formatting_state, no_formatting)) - combined = [handle_call_part(call[0])] - - for i in range(len(call) - 1): - combined.append('[' + sanitize(responses[i]) + ']') - combined.append(handle_call_part(call[i + 1])) - - return ''.join(combined) + return ''.join(r) def combine_played(call, player_bot, selected_cards): return combine_cards(call.text, [player_bot.hand[i].text for i in selected_cards]) @@ -720,14 +956,14 @@ def game(send, notice, voice, devoice, get_event): choosers = [i for i in players.values() if i is not czar] - send('Round %i. %s is czar. %s choose your cards' % (round_number, czar.nick, ', '.join(i.nick for i in choosers))) - send('[%s]' % '_'.join(sanitize(part) for part in round_call_card.text)) - # Round call card has N parts. Between each of those parts # goes one response card. Therefore there should be N - 1 # response cards num_blanks = len(round_call_card.text) - 1 + 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.text, ['_'] * num_blanks)) + # Have bots choose first for bot in bots.values(): card_choices[bot] = bot.play(num_blanks)