Example #1
0
    def __init__(self, prefix, tracker_version, log_finder):
        self.state = TrackerState("", tracker_version,
                                  Options().game_version, "", "", -1)
        self.log = logging.getLogger("tracker")
        self.wdir_prefix = prefix
        self.log_finder = log_finder

        self.reset()
    def __init__(self, prefix, tracker_version):
        self.state = TrackerState("", tracker_version, Options().game_version)
        self.log = logging.getLogger("tracker")
        self.file_prefix = prefix

        self.reset()

        # FIXME run summary.
        self.run_ended = 0
Example #3
0
    def __init__(self, prefix, tracker_version, log_finder):
        self.state = TrackerState("", tracker_version, Options().game_version)
        self.log = logging.getLogger("tracker")
        self.wdir_prefix = prefix
        self.log_finder = log_finder

        self.reset()
    def __init__(self, prefix, tracker_version):
        self.state = TrackerState("", tracker_version)
        self.log = logging.getLogger("tracker")
        self.file_prefix = prefix

        self.game_version = ""
        self.__reset()

        # FIXME run summary.
        self.run_ended = 0
    def run(self):
        """ The main routine which controls everything """

        update_notifier = self.check_for_update()
        framecount = 0

        # Create drawing tool to use to draw everything - it'll create its own screen
        drawing_tool = DrawingTool(self.file_prefix)
        drawing_tool.set_window_title(update_notifier)
        parser = LogParser(self.file_prefix, self.tracker_version)
        opt = Options()
        log = logging.getLogger("tracker")

        event_result = None
        state = None
        read_from_server = opt.read_from_server
        write_to_server = opt.write_to_server
        state_version = -1
        twitch_username = None
        new_states_queue = []
        screen_error_message = None

        while event_result != Event.DONE:

            # Check for events and handle them
            event_result = drawing_tool.handle_events()
            # A change means the user has (de)activated an option
            if opt.read_from_server != read_from_server\
            or opt.twitch_name != twitch_username:
                twitch_username = opt.twitch_name
                read_from_server = opt.read_from_server
                new_states_queue = []
                # Also restart version count if we go back and forth from log.txt to server
                if read_from_server:
                    state_version = -1
                    state = None
                    # show who we are watching in the title bar
                    drawing_tool.set_window_title(update_notifier, watching_player=twitch_username, updates_queued=len(new_states_queue))
                else:
                    drawing_tool.set_window_title(update_notifier)

            if opt.write_to_server and opt.write_to_server != write_to_server:
                write_to_server = True
                drawing_tool.set_window_title(update_notifier, uploading=True)

            if not opt.write_to_server:
                write_to_server = False

            if opt.read_from_server:
                # Change the delay for polling, as we probably don't want to fetch it every second
                update_timer = 2
            else:
                update_timer = self.read_timer

            if event_result == Event.OPTIONS_UPDATE:
                # By setting the framecount to 0 we ensure we'll refresh the state right away
                framecount = 0
                screen_error_message = None
                # force updates after changing options
                if state is not None:
                    state.modified = True


            # Now we re-process the log file to get anything that might have loaded;
            # do it every update_timer seconds (making sure to truncate to an integer
            # or else it might never mod to 0)
            if (framecount % int(Options().framerate_limit * update_timer) == 0):
                # Let the parser do his thing and give us a state
                if opt.read_from_server:
                    base_url = opt.trackerserver_url + "/tracker/api/user/" + opt.twitch_name
                    json_dict = None
                    try:
                        json_version = urllib2.urlopen(base_url + "/version").read()
                        if int(json_version) > state_version:
                            # FIXME better handling of 404 error ?
                            json_state = urllib2.urlopen(base_url).read()
                            json_dict = json.loads(json_state)
                            new_state = TrackerState.from_json(json_dict)
                            if new_state is None:
                                raise Exception
                            state_version = int(json_version)
                            new_states_queue.append((state_version, new_state))
                            drawing_tool.set_window_title(update_notifier, watching_player=twitch_username, updates_queued=len(new_states_queue), read_delay=opt.read_delay)
                    except Exception:
                        state = None
                        log.error("Couldn't load state from server")
                        import traceback
                        log.error(traceback.format_exc())
                        if json_dict is not None:
                            their_version = ""
                            if "tracker_version" in json_dict:
                                their_version = json_dict["tracker_version"]
                            else:
                                # this is the only version that can upload to the server but doesn't include a version string
                                their_version = "0.10-beta1"

                            if their_version != self.tracker_version:
                                screen_error_message = "They are using tracker version " + their_version + " but you have " + self.tracker_version
                else:
                    force_draw = state and state.modified
                    state = parser.parse()
                    if force_draw:
                        state.modified = True
                    if write_to_server and not opt.trackerserver_authkey:
                        screen_error_message = "Your authkey is blank. Get a new authkey in the options menu and paste it into the authkey text field."
                    if state is not None and write_to_server and state.modified and screen_error_message is None:
                        opener = urllib2.build_opener(urllib2.HTTPHandler)
                        put_url = opt.trackerserver_url + "/tracker/api/update/" + opt.trackerserver_authkey
                        json_string = json.dumps(state, cls=TrackerStateEncoder, sort_keys=True)
                        request = urllib2.Request(put_url,
                                                  data=json_string)
                        request.add_header('Content-Type', 'application/json')
                        request.get_method = lambda: 'PUT'
                        try:
                            result = opener.open(request)
                            result_json = json.loads(result.read())
                            updated_user = result_json["updated_user"]
                            if updated_user is None:
                                screen_error_message = "The server didn't recognize you. Try getting a new authkey in the options menu."
                            else:
                                screen_error_message = None
                        except Exception as e:
                            import traceback
                            errmsg = traceback.format_exc()
                            log.error("ERROR: Couldn't send item info to server")
                            log.error(errmsg)
                            screen_error_message = "ERROR: Couldn't send item info to server, check tracker_log.txt"


            # check the new state at the front of the queue to see if it's time to use it
            if len(new_states_queue) > 0:
                (state_timestamp, new_state) = new_states_queue[0]
                current_timestamp = int(time.time())
                if current_timestamp - state_timestamp >= opt.read_delay or state is None:
                    state = new_state
                    new_states_queue.pop(0)
                    drawing_tool.set_window_title(update_notifier, watching_player=twitch_username, updates_queued=len(new_states_queue), read_delay=opt.read_delay)



            if state is None and screen_error_message is None:
                if read_from_server:
                    screen_error_message = "Unable to read state from server. Please verify your options setup and tracker_log.txt"
                else:
                    screen_error_message = "log.txt not found. Put the RebirthItemTracker folder inside the isaac folder, next to log.txt"

            if screen_error_message is not None:
                drawing_tool.write_error_message(screen_error_message)
            else:
                # We got a state, now we draw it
                drawing_tool.draw_state(state)

            drawing_tool.tick()
            framecount += 1

        # main loop finished. program is exiting
        drawing_tool.save_window_position()
Example #6
0
    def run(self):
        """ The main routine which controls everything """
        framecount = 0

        # Create drawing tool to use to draw everything - it'll create its own screen
        drawing_tool = DrawingTool(wdir_prefix)
        drawing_tool.set_window_title_info(
            update_notifier=(" v" + self.tracker_version))
        opt = Options()

        parser = LogParser(wdir_prefix, self.tracker_version, LogFinder())

        event_result = None
        state = None
        custom_title_enabled = opt.custom_title_enabled
        read_from_server = opt.read_from_server
        write_to_server = opt.write_to_server
        game_version = opt.game_version
        state_version = -1
        twitch_username = None
        new_states_queue = []
        screen_error_message = None
        retry_in = 0
        update_timer = opt.log_file_check_seconds
        last_game_version = None

        while event_result != Event.DONE:
            # Check for events and handle them
            event_result = drawing_tool.handle_events()

            # The user checked or unchecked the "Custom Title Enabled" checkbox
            if opt.custom_title_enabled != custom_title_enabled:
                custom_title_enabled = opt.custom_title_enabled
                drawing_tool.update_window_title()

            # The user started or stopped watching someone from the server (or they started watching a new person from the server)
            if opt.read_from_server != read_from_server or opt.twitch_name != twitch_username:
                twitch_username = opt.twitch_name
                read_from_server = opt.read_from_server
                new_states_queue = []
                # Also restart version count if we go back and forth from log.txt to server
                if read_from_server:
                    state_version = -1
                    state = None
                    # Change the delay for polling, as we probably don't want to fetch it every second
                    update_timer_override = 2
                    # Show who we are watching in the title bar
                    drawing_tool.set_window_title_info(
                        watching=True,
                        watching_player=twitch_username,
                        updates_queued=len(new_states_queue))
                else:
                    drawing_tool.set_window_title_info(watching=False)
                    update_timer_override = 0

            # The user started or stopped broadcasting to the server
            if opt.write_to_server != write_to_server:
                write_to_server = opt.write_to_server
                drawing_tool.set_window_title_info(
                    uploading=opt.write_to_server)

            if opt.game_version != game_version:
                parser.reset()
                game_version = opt.game_version

            # Force refresh state if we updated options or if we need to retry
            # to contact the server.
            if (event_result == Event.OPTIONS_UPDATE
                    or (screen_error_message is not None and retry_in == 0)):
                # By setting the framecount to 0 we ensure we'll refresh the state right away
                framecount = 0
                screen_error_message = None
                retry_in = 0
                # Force updates after changing options
                if state is not None:
                    state.modified = True

            # normally we check for updates based on how the option is set
            # when doing network stuff, this can be overridden
            update_delay = opt.log_file_check_seconds
            if update_timer_override != 0:
                update_delay = update_timer_override

            # Now we re-process the log file to get anything that might have loaded;
            # do it every update_timer seconds (making sure to truncate to an integer
            # or else it might never mod to 0)
            frames_between_checks = int(Options().framerate_limit *
                                        update_delay)
            if frames_between_checks <= 0:
                frames_between_checks = 1

            if framecount % frames_between_checks == 0:
                if retry_in != 0:
                    retry_in -= 1
                # Let the parser do his thing and give us a state
                if opt.read_from_server:
                    base_url = opt.trackerserver_url + "/tracker/api/user/" + opt.twitch_name
                    json_dict = None
                    try:
                        json_version = urllib.request.urlopen(
                            base_url + "/version").read()
                        if int(json_version) > state_version:
                            # FIXME better handling of 404 error ?
                            json_state = urllib.request.urlopen(
                                base_url).read()
                            json_dict = json.loads(json_state)
                            new_state = TrackerState.from_json(json_dict)
                            if new_state is None:
                                raise Exception("server gave us empty state")
                            state_version = int(json_version)
                            new_states_queue.append((state_version, new_state))
                            drawing_tool.set_window_title_info(
                                updates_queued=len(new_states_queue))
                    except Exception:
                        state = None
                        log_error("Couldn't load state from server\n" +
                                  traceback.format_exc())
                        if json_dict is not None:
                            if "tracker_version" in json_dict:
                                their_version = json_dict["tracker_version"]
                            else:
                                # This is the only version that can upload to the server but doesn't include a version string
                                their_version = "0.10-beta1"

                            if their_version != self.tracker_version:
                                screen_error_message = "They are using tracker version " + their_version + " but you have " + self.tracker_version
                else:
                    force_draw = state and state.modified
                    state = parser.parse()
                    if force_draw and state is not None:
                        state.modified = True
                    if write_to_server and not opt.trackerserver_authkey:
                        screen_error_message = "Your authkey is blank. Get a new authkey in the options menu and paste it into the authkey text field."
                    if state is not None and write_to_server and state.modified and screen_error_message is None:
                        opener = urllib.request.build_opener(
                            urllib.request.HTTPHandler)
                        put_url = opt.trackerserver_url + "/tracker/api/update/" + opt.trackerserver_authkey
                        json_string = json.dumps(
                            state, cls=TrackerStateEncoder,
                            sort_keys=True).encode("utf-8")
                        request = urllib.request.Request(put_url,
                                                         data=json_string)
                        request.add_header('Content-Type', 'application/json')
                        request.get_method = lambda: 'PUT'
                        try:
                            result = opener.open(request)
                            result_json = json.loads(result.read())
                            updated_user = result_json["updated_user"]
                            if updated_user is None:
                                screen_error_message = "The server didn't recognize you. Try getting a new authkey in the options menu."
                            else:
                                screen_error_message = None
                        except Exception as e:
                            log_error(
                                "ERROR: Couldn't send item info to server\n" +
                                traceback.format_exc())
                            screen_error_message = "ERROR: Couldn't send item info to server, check tracker_log.txt"
                            # Retry to write the state in 10*update_timer (aka 10 sec in write mode)
                            retry_in = 10

            # Check the new state at the front of the queue to see if it's time to use it
            if len(new_states_queue) > 0:
                (state_timestamp, new_state) = new_states_queue[0]
                current_timestamp = int(time.time())
                if current_timestamp - state_timestamp >= opt.read_delay or opt.read_delay == 0 or state is None:
                    state = new_state
                    new_states_queue.pop(0)
                    drawing_tool.set_window_title_info(
                        updates_queued=len(new_states_queue))

            if state is None and screen_error_message is None:
                if read_from_server:
                    screen_error_message = "Unable to read state from server. Please verify your options setup and tracker_log.txt"
                    # Retry to read the state in 5*update_timer (aka 10 sec in read mode)
                    retry_in = 5
                else:
                    screen_error_message = "log.txt for " + opt.game_version + " not found. Make sure you have the right game selected in the options."

            if screen_error_message is not None:
                drawing_tool.write_error_message(screen_error_message)
            else:
                # We got a state, now we draw it
                drawing_tool.draw_state(state, framecount)

            # if we're watching someone and they change their game version, it can require us to reset
            if state and last_game_version != state.game_version:
                drawing_tool.reset_options()
                last_game_version = state.game_version

            drawing_tool.tick()
            framecount += 1

        # Main loop finished; program is exiting
        drawing_tool.save_window_position()
        Options().save_options(wdir_prefix + "options.json")
Example #7
0
class LogParser(object):
    """
    This class loads Isaac's log file, and incrementally modify a state representing this log
    """
    def __init__(self, prefix, tracker_version, log_finder):
        self.state = TrackerState("", tracker_version,
                                  Options().game_version, "", "", -1)
        self.log = logging.getLogger("tracker")
        self.wdir_prefix = prefix
        self.log_finder = log_finder

        self.reset()

    def reset(self):
        """Reset variable specific to the log file/run"""
        # Variables describing the parser state
        self.getting_start_items = False
        self.reseeding_floor = False
        self.current_seed = ""
        # Cached contents of log
        self.content = ""
        # Log split into lines
        self.splitfile = []
        self.run_start_line = 0
        self.seek = 0
        self.spawned_coop_baby = 0
        self.log_file_handle = None
        # if they switched between rebirth and afterbirth, the log file we use could change
        self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix)
        self.state.reset(self.current_seed, Options().game_version, "")
        self.greed_mode_starting_rooms = (
            '1.1000', '1.1010', '1.1011', '1.1012', '1.1013', '1.1014',
            '1.1015', '1.1016', '1.1017', '1.1018', '1.2000', '1.2001',
            '1.2002', '1.2003', '1.2004', '1.2005', '1.2006', '1.2007',
            '1.2008', '1.2009', '1.3000', '1.3001', '1.3002', '1.3003',
            '1.3004', '1.3005', '1.3006', '1.3007', '1.3008', '1.3009',
            '1.3010', '1.4000', '1.4001', '1.4002', '1.4003', '1.4004',
            '1.4005', '1.4006', '1.4007', '1.4008', '1.4009', '1.4010',
            '1.5000', '1.5001', '1.5002', '1.5003', '1.5004', '1.5005',
            '1.5006', '1.5007', '1.5008', '1.5009', '1.5010', '1.6000',
            '1.6001', '1.6002', '1.6003', '1.6004', '1.6005', '1.6006',
            '1.6007', '1.6008', '1.6009')
        self.first_floor = None
        self.first_line = ""
        self.curse_first_floor = ""

    def parse(self):
        """
        Parse the log file and return a TrackerState object,
        or None if the log file couldn't be found
        """

        self.opt = Options()
        # Attempt to load log_file
        if not self.__load_log_file():
            return None
        self.splitfile = self.content.splitlines()

        # This will become true if we are getting starting items
        self.getting_start_items = False

        # Process log's new output
        for current_line_number, line in enumerate(self.splitfile[self.seek:]):
            self.__parse_line(current_line_number, line)

        self.seek = len(self.splitfile)
        return self.state

    def __parse_line(self, line_number, line):
        """
        Parse a line using the (line_number, line) tuple
        """
        # In Afterbirth+, nearly all lines start with this.
        # We want to slice it off.
        info_prefix = '[INFO] - '
        if line.startswith(info_prefix):
            line = line[len(info_prefix):]

        # Messages printed by mods have this prefix.
        # strip it, so mods can spoof actual game log messages to us if they want to
        luadebug_prefix = 'Lua Debug: '
        if line.startswith(luadebug_prefix):
            line = line[len(luadebug_prefix):]

        # AB and AB+ version messages both start with this text (AB+ has a + at the end)
        if line.startswith('Binding of Isaac: Repentance') or line.startswith(
                'Binding of Isaac: Afterbirth') or line.startswith(
                    'Binding of Isaac: Rebirth'):
            self.__parse_version_number(line)
        if line.startswith('welcomeBanner:'):
            self.__parse_version_number(line, True)
        if line.startswith('Loading PersistentData'):
            self.__parse_save(line)
        if line.startswith('RNG Start Seed:'):
            self.__parse_seed(line, line_number)
        if line.startswith(
                'Initialized player with Variant') and self.state.player == -1:
            self.__parse_player(line)
        if self.opt.game_version == "Repentance" and line.startswith(
                'Level::Init'
        ) and self.state.greedmode is None:  # Store the line of the first floor in Repentance because we can detect if we are in greed mode only after this line in the log
            self.first_line = line
            self.curse_first_floor = ""
        elif line.startswith('Level::Init'):
            self.__parse_floor(line, line_number)
        if line.startswith('Room'):
            self.__parse_room(line)
            if self.opt.game_version == "Repentance":
                self.detect_greed_mode(line, line_number)
                self.state.remove_additional_char_items()
        if line.startswith("Curse"):
            self.__parse_curse(line)
        if line.startswith("Spawn co-player!"):
            self.spawned_coop_baby = line_number + self.seek
        if re.search(r"Added \d+ Collectibles", line):
            self.log.debug("Reroll detected!")
            self.state.reroll()
        if line.startswith('Adding collectible '):
            self.__parse_item_add(line_number, line)
        if line.startswith('Gulping trinket ') or line.startswith(
                'Adding smelted trinket '):
            self.__parse_trinket_gulp(line)
        if line.startswith('Removing collectible ') or line.startswith(
                'Removing smelted trinket '):
            self.__parse_item_remove(line)
        if line.startswith('Executing command: reseed'):
            # racing+ re-generates floors if they contain duplicate rooms. we need to track that this is happening
            # so we don't erroneously think the entire run is being restarted when it happens on b1.
            self.reseeding_floor = True
        if line.startswith('Caught exception,'):
            self.__backup_log(crash=True)
        if line.startswith('Isaac has shut down'):
            self.__backup_log()

    def __trigger_new_run(self, line_number):
        self.log.debug("Starting new run, seed: %s", self.current_seed)
        self.run_start_line = line_number + self.seek
        self.state.reset(self.current_seed,
                         Options().game_version,
                         self.state.racing_plus_version)

    def __parse_version_number(self, line, racingplus=False):
        words = line.split()
        if not racingplus:
            self.state.version_number = words[-1]
        else:
            regexp_str = r"welcomeBanner:(\d+) - [|] Racing[+] (\d+).(\d+).(\d+) initialized."
            search_result = re.search(regexp_str, line)
            if search_result is None:
                return False
            self.state.racing_plus_version = "/ R+: " + str(
                int(search_result.group(2))) + "." + str(
                    int(search_result.group(3))) + "." + str(
                        int(search_result.group(
                            4))) if search_result is not None else ""

    def __parse_save(self, line):
        regexp_str = r"Loading PersistentData (\d+)"
        search_result = re.search(regexp_str, line)
        self.state.save = int(
            search_result.group(1)) if search_result is not None else 0

    def __parse_seed(self, line, line_number):
        """ Parse a seed line """
        # This assumes a fixed width, but from what I see it seems safe
        self.current_seed = line[16:25]
        space_split = line.split(" ")

        # Antibirth doesn't have a proper way to detect run resets
        # it will wipe the tracker when doing a "continue"
        if (self.opt.game_version == "Repentance" and space_split[6] in
            ('[New,', '[Daily,')) or self.opt.game_version == "Antibirth":
            self.__trigger_new_run(line_number)
        elif (self.opt.game_version == "Repentance"
              and space_split[6] == '[Continue,'):
            self.state.load_from_export_state()

    def __parse_player(self, line):
        regexp_str = r"Initialized player with Variant (\d+) and Subtype (\d+)"
        search_result = re.search(regexp_str, line)
        self.state.player = int(
            search_result.group(2)
        ) if search_result is not None else 8  # Put it on Lazarus by default

    def __parse_room(self, line):
        """ Parse a room line """
        if 'Start Room' not in line:
            self.getting_start_items = False

        match = re.search(r"Room (.+?)\(", line)
        if match:
            room_id = match.group(1)
            self.state.change_room(room_id)

    def detect_greed_mode(self, line, line_number):
        # Detect if we're in Greed mode or not in Repentance. We must do a ton of hacky things to show the first floor with curses because we can't detect greed mode in one line anymore
        match = re.search(r"Room (.+?)\(", line)
        if match:
            room_id = match.group(1)
            if room_id == '18.1000':  # Genesis room
                self.state.item_list = []
                self.state.set_transformations()
            elif self.state.greedmode is None:
                self.state.greedmode = room_id in self.greed_mode_starting_rooms
                self.__parse_floor(self.first_line, line_number)
                self.__parse_curse(self.curse_first_floor)

    def __parse_floor(self, line, line_number):
        """ Parse the floor in line and push it to the state """
        # Create a floor tuple with the floor id and the alternate id
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance":
            regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)"
        elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth":
            regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)"
        else:
            return
        search_result = re.search(regexp_str, line)
        if search_result is None:
            self.log.debug(
                "log.txt line doesn't match expected regex\nline: \"" + line +
                "\"\nregex:\"" + regexp_str + "\"")
            return

        floor = int(search_result.group(1))
        alt = search_result.group(2)
        self.getting_start_items = True

        # we use generation of the first floor as our trigger that a new run started.
        # in racing+, it doesn't count if the game is currently in the process of "reseeding" that floor.
        # in antibirth, this doesn't work at all; instead we have to use the seed being printed as our trigger.
        # that means if you s+q in antibirth, it resets the tracker.
        # In Repentance, Downpour 1 and Dross 1 are considered Stage 1.
        # So we need to add a condition to avoid tracker resetting when entering those floors.
        # In Repentance, don't trigger a new run on floor 1 because of the R Key item
        if self.reseeding_floor:
            self.reseeding_floor = False
        elif floor == 1 and self.opt.game_version != "Antibirth" and self.opt.game_version != "Repentance":
            self.__trigger_new_run(line_number)

        # Special handling for the Cathedral and The Chest and Afterbirth
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance":
            self.log.debug("floor")
            # In Afterbirth, Cath is an alternate of Sheol (which is 10)
            # and Chest is an alternate of Dark Room (which is 11)
            # In Repentance, alt paths are same stage as their counterparts (ex: Basement 1 = Downpour 1)
            if alt == '4' or alt == '5':
                floor += 15
            elif floor == 10 and alt == '0':
                floor -= 1
            elif floor == 11 and alt == '1':
                floor += 1
            elif floor == 9:
                floor = 13
            elif floor == 12:
                floor = 14
            elif floor == 13:
                floor = 15
        else:
            # In Rebirth, floors have different numbers
            if alt == '1' and (floor == 9 or floor == 11):
                floor += 1
        floor_id = 'f' + str(floor)

        # Greed mode
        if (alt == '3' and self.opt.game_version != "Repentance") or (
                self.opt.game_version == "Repentance"
                and self.state.greedmode):
            floor_id += 'g'

        self.state.add_floor(Floor(floor_id))
        self.state.export_state()
        return True

    def __parse_curse(self, line):
        """ Parse the curse and add it to the last floor """
        if self.state.racing_plus_version != "":
            return
        if self.curse_first_floor == "":
            self.curse_first_floor = line
        elif self.state.greedmode is not None:
            self.curse_first_floor = ""
        if line.startswith("Curse of the Labyrinth!") or (
                self.curse_first_floor == "Curse of the Labyrinth!"
                and self.opt.game_version == "Repentance"):
            self.state.add_curse(Curse.Labyrinth)
        if line.startswith("Curse of Blind") or (
                self.curse_first_floor == "Curse of Blind"
                and self.opt.game_version == "Repentance"):
            self.state.add_curse(Curse.Blind)

    def __parse_item_add(self, line_number, line):
        """ Parse an item and push it to the state """
        if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek -
                                                      1] == line:
            self.log.debug("Skipped duplicate item line from baby presence")
            return False
        is_Jacob_item = line.endswith(
            "(Jacob)"
        ) and self.opt.game_version == "Repentance" and self.state.player == 19
        is_Esau_item = line.endswith(
            " 1 (Esau)"
        ) and self.opt.game_version == "Repentance" and self.state.player == 19
        if self.state.player in (
                14, 33):  # Don't show keeper head on keeper and tainted keeper
            is_Strawman_item = "player 0" not in line and line.endswith(
                "(Keeper)") and self.state.contains_item('667')
            is_EsauSoul_item = "player 0" not in line and line.endswith(
                "(Esau)")
        elif self.state.player == 19:
            is_Strawman_item = line.endswith(
                "(Keeper)") and self.state.contains_item('667')
            is_EsauSoul_item = "player 0" not in line and "player 1 " not in line and line.endswith(
                "(Esau)")
        else:
            is_Strawman_item = line.endswith(
                "(Keeper)") and self.state.contains_item('667')
            is_EsauSoul_item = "player 0" not in line and line.endswith(
                "(Esau)")

        if self.state.player == 19 and not is_Esau_item and not is_Jacob_item and not is_Strawman_item and not is_EsauSoul_item:  # This is when J&E transform into another character
            self.state.player = 8  # Put it on Lazarus by default just in case we got another Anemic
        elif self.state.player not in (19, 37) and is_Jacob_item:
            self.state.player = 19

        space_split = line.split(" ")
        numeric_id = space_split[
            2]  # When you pick up an item, this has the form: "Adding collectible 105 (The D6)" or "Adding collectible 105 (The D6) to Player 0 (Isaac)" in Repentance
        double_word_char = line.endswith("(The Lost)") or line.endswith(
            "(The Forgotten)") or line.endswith("(The Soul)") or line.endswith(
                "(Black Judas)") or line.endswith("(Random Baby)")
        if self.opt.game_version == "Repentance" and double_word_char:
            item_name = " ".join(space_split[3:-4])[1:-4]
        elif self.opt.game_version == "Repentance":
            item_name = " ".join(space_split[3:-4])[1:-1]
        else:
            item_name = " ".join(space_split[3:])[1:-1]

        item_id = ""

        if int(numeric_id) < 0:
            numeric_id = "-1"

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            # it might be a modded custom item. let's see if we recognize the name
            item_id = Item.modded_item_id_prefix + item_name
            if not Item.contains_info(item_id):
                item_id = "NEW"

        self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name)
        if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \
                and self.state.contains_item(item_id):
            self.log.debug("Skipped duplicate item line from baby entry")
            return False

        # It's a blind pickup if we're on a blind floor and we don't have the Black Candle
        blind_pickup = self.state.last_floor.floor_has_curse(
            Curse.Blind) and not self.state.contains_item('260')
        if not (numeric_id == "214" and (
            (self.state.contains_item('214')
             and self.state.contains_item('332')) or
            (self.state.player == 8 and self.state.contains_item('214')))):
            added = self.state.add_item(
                Item(item_id,
                     numeric_id,
                     self.state.last_floor,
                     self.getting_start_items,
                     blind=blind_pickup,
                     is_Jacob_item=is_Jacob_item,
                     is_Esau_item=is_Esau_item,
                     is_Strawman_item=is_Strawman_item,
                     is_EsauSoul_item=is_EsauSoul_item,
                     shown=Item.get_item_info(item_id).shown))
            if not added:
                self.log.debug(
                    "Skipped adding item %s to avoid space-bar duplicate",
                    item_id)
        else:
            self.log.debug(
                "Skipped adding Anemic from Lazarus Rags because we already have Anemic"
            )

        if item_id in ("144", "238", "239", "278", "388", "550", "552", "626",
                       "627"):
            self.__parse_add_multi_items()
        self.state.export_state()
        return True

    def __parse_add_multi_items(self):
        """Add custom sprites for multi-segmented items like Super Bum, key pieces or knife pieces"""
        # item.info.shown = False is for not showing the item on the tracker
        # item.shown = False is for the export_state function to store the actual shown value instead of the initial value item.info.shown
        if self.state.contains_item('238') and self.state.contains_item(
                '239') and not self.state.contains_item('3000'):
            for item in reversed(self.state.item_list):
                if item.item_id in ("238", "239"):
                    item.info.shown = False
                    item.shown = False
            self.state.add_item(
                Item("3000", "3000", floor=self.state.last_floor))
        elif self.state.contains_item('550') and self.state.contains_item(
                '552'):
            for item in reversed(self.state.item_list):
                if item.item_id == "550":
                    item.info.shown = False
                    item.shown = False
        elif self.state.contains_item('144') and self.state.contains_item(
                '278'
        ) and self.state.contains_item('388') and not self.state.contains_item(
                '3001'
        ) and self.opt.game_version != "Rebirth" and self.opt.game_version != "Antibirth":
            for item in reversed(self.state.item_list):
                if item.item_id in ("144", "278", "388"):
                    item.info.shown = False
                    item.shown = False
            self.state.add_item(
                Item("3001", "3001", floor=self.state.last_floor))
        elif self.state.contains_item('626') and self.state.contains_item(
                '627') and not self.state.contains_item('3002'):
            for item in reversed(self.state.item_list):
                if item.item_id in ("626", "627"):
                    item.info.shown = False
                    item.shown = False
            self.state.add_item(
                Item("3002", "3002", floor=self.state.last_floor))

    def __parse_trinket_gulp(self, line):
        """ Parse a (modded) trinket gulp and push it to the state """
        space_split = line.split(" ")
        # When using a mod like racing+ on AB+, a trinket gulp has the form: "Gulping trinket 10"
        # In Repentance, a gulped trinket has the form : "Adding smelted trinket 10"
        if self.opt.game_version == "Repentance" and int(
                space_split[3]) > 30000:
            numeric_id = str(int(space_split[3]))
        elif self.opt.game_version == "Repentance":
            numeric_id = str(
                int(space_split[3]) + 2000
            )  # the tracker hackily maps trinkets to items 2000 and up.
        else:
            numeric_id = str(
                int(space_split[2]) + 2000
            )  # the tracker hackily maps trinkets to items 2000 and up.
        is_Jacob_item = line.endswith(
            "(Jacob)"
        ) and self.opt.game_version == "Repentance" and self.state.player == 19
        is_Esau_item = line.endswith(
            "(Esau)") and self.opt.game_version == "Repentance"

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            item_id = "NEW"

        numeric_id = "t" + str(numeric_id) if item_id == "NEW" else item_id

        self.log.debug("Gulped trinket: %s", item_id)

        added = self.state.add_item(
            Item(item_id,
                 numeric_id,
                 self.state.last_floor,
                 self.getting_start_items,
                 is_Jacob_item=is_Jacob_item,
                 is_Esau_item=is_Esau_item))
        if not added:
            self.log.debug(
                "Skipped adding item %s to avoid space-bar duplicate", item_id)
        self.state.export_state()
        return True

    def __parse_item_remove(self, line):
        """ Parse an item and remove it from the state """
        space_split = line.split(" ")  # Hacky string manipulation
        double_word_char = line.endswith("(The Lost)") or line.endswith(
            "(The Forgotten)") or line.endswith("(The Soul)") or line.endswith(
                "(Black Judas)") or line.endswith("(Random Baby)")
        # When you lose an item, this has the form: "Removing collectible 105 (The D6)"
        if self.opt.game_version == "Repentance" and space_split[
                2] == "trinket" and int(space_split[3]) < 30000:
            item_id = str(int(space_split[3]) + 2000)
            item_name = " ".join(
                space_split[3:-5])[3:-1] if double_word_char else " ".join(
                    space_split[3:-4])[3:-1]
        else:
            item_id = space_split[2]
            item_name = " ".join(
                space_split[3:-5])[1:-1] if double_word_char else " ".join(
                    space_split[3:-4])[1:-1]

        # Check if the item ID exists
        if Item.contains_info(item_id):
            removal_id = item_id
        else:
            # that means it's probably a custom item
            removal_id = Item.modded_item_id_prefix + item_name

        self.log.debug("Removed item. id: %s", removal_id)

        if item_id in ("144", "238", "239", "278", "388", "626", "627"):
            self.__parse_remove_multi_items(item_id=item_id)

        if item_id == "667":
            self.state.remove_additional_char_items(strawman=True)

        # A check will be made inside the remove_item function
        # to see if this item is actually in our inventory or not.
        return self.state.remove_item(removal_id)

    def __parse_remove_multi_items(self, item_id):
        """Remove custom sprites for multi-segmented items like Super Bum, key pieces or knife pieces"""
        if item_id in ("238", "239"):
            for item in reversed(self.state.item_list):
                if item.item_id in ("238", "239"):
                    item.info.shown = True
            self.state.remove_item("3000")
        elif item_id in ("144", "278", "388"):
            for item in reversed(self.state.item_list):
                if item.item_id in ("144", "278", "388"):
                    item.info.shown = True
            self.state.remove_item("3001")
        elif item_id in ("626", "627"):
            for item in reversed(self.state.item_list):
                if item.item_id in ("626", "627"):
                    item.info.shown = True
            self.state.remove_item("3002")

    def __load_log_file(self):
        if self.log_file_path is None:
            return False

        if self.log_file_handle is None:
            self.log_file_handle = open(self.log_file_path,
                                        'r',
                                        encoding='Latin-1',
                                        errors='remplace')

        cached_length = len(self.content)
        file_size = os.path.getsize(self.log_file_path)

        if cached_length > file_size or cached_length == 0:  # New log file or first time loading the log
            self.reset()
            self.content = open(self.log_file_path,
                                'r',
                                encoding='Latin-1',
                                errors='remplace').read()
        elif cached_length < file_size:  # Append existing content
            self.log_file_handle.seek(cached_length + 1)
            self.content += self.log_file_handle.read()
        return True

    def __backup_log(self, crash=False):
        if self.log_file_path is None:
            return False

        logsTarget = '/backup logs'
        if not os.path.exists(self.wdir_prefix + logsTarget):
            os.mkdir(self.wdir_prefix + logsTarget)

        target = self.wdir_prefix + logsTarget
        racingPlusLogsTarget = '/Racing+ logs'

        if self.state.racing_plus_version != "":
            if not os.path.exists(target + racingPlusLogsTarget):
                os.mkdir(target + racingPlusLogsTarget)
            target += racingPlusLogsTarget

        now = datetime.now()
        logTime = now.strftime("%d-%m-%Y %H-%M-%S")

        target += '/' + logTime
        if crash:
            target = target + " [crash]"

        if self.state.racing_plus_version != "":
            racingPlusVersion = self.state.racing_plus_version.replace(
                "/ R+: ", "")
            target = target + " - " + racingPlusVersion

        versionNumber = self.state.version_number.replace("v1", "1").replace(
            ".0000", "")
        target = target + " - " + versionNumber + ".txt"

        shutil.copy(self.log_file_path, target)

        if os.path.exists(self.wdir_prefix + logsTarget +
                          racingPlusLogsTarget):
            self.__delete_last_logs("backup logs/", 1)
            self.__delete_last_logs("backup logs/Racing+ logs/", 0)
        else:
            self.__delete_last_logs("backup logs/", 0)

    def __delete_last_logs(self, folder, numFolders):
        path = self.wdir_prefix + folder
        mtime = lambda f: os.stat(os.path.join(path, f)).st_mtime
        sorted_ls = list(sorted(os.listdir(path), key=mtime))
        if len(sorted_ls) >= 200:
            del_list = sorted_ls[numFolders:(len(sorted_ls) - 200)]
            for file in del_list:
                os.remove(path + file)
Example #8
0
class LogParser(object):
    """
    This class loads Isaac's log file, and incrementally modify a state representing this log
    """
    def __init__(self, prefix, tracker_version, log_finder):
        self.state = TrackerState("", tracker_version, Options().game_version)
        self.log = logging.getLogger("tracker")
        self.wdir_prefix = prefix
        self.log_finder = log_finder

        self.reset()

    def reset(self):
        """Reset variable specific to the log file/run"""
        # Variables describing the parser state
        self.getting_start_items = False
        self.reseeding_floor = False
        self.current_room = ""
        self.current_seed = ""
        self.seedline = ""
        # Cached contents of log
        self.content = ""
        # Log split into lines
        self.splitfile = []
        self.run_start_line = 0
        self.seek = 0
        self.spawned_coop_baby = 0
        self.log_file_handle = None
        # if they switched between rebirth and afterbirth, the log file we use could change
        self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix)
        self.state.reset(self.current_seed, Options().game_version)
        self.lazarus = False
        self.greedmode = 0
        self.first_floor = None
        self.first_line = ""
        self.curse_first_floor = ""
        # Avoid tracker resetting on B1 for Backasswards challenge
        self.backasswards = False

    def parse(self):
        """
        Parse the log file and return a TrackerState object,
        or None if the log file couldn't be found
        """

        self.opt = Options()
        # Attempt to load log_file
        if not self.__load_log_file():
            return None
        self.splitfile = self.content.splitlines()

        # This will become true if we are getting starting items
        self.getting_start_items = False

        # Process log's new output
        for current_line_number, line in enumerate(self.splitfile[self.seek:]):
            self.__parse_line(current_line_number, line)

        self.seek = len(self.splitfile)
        return self.state

    def __parse_line(self, line_number, line):
        """
        Parse a line using the (line_number, line) tuple
        """
        # In Afterbirth+, nearly all lines start with this.
        # We want to slice it off.
        info_prefix = '[INFO] - '
        if line.startswith(info_prefix):
            line = line[len(info_prefix):]

        # Messages printed by mods have this prefix.
        # strip it, so mods can spoof actual game log messages to us if they want to
        luadebug_prefix = 'Lua Debug: '
        if line.startswith(luadebug_prefix):
            line = line[len(luadebug_prefix):]

        # AB and AB+ version messages both start with this text (AB+ has a + at the end)
        if line.startswith('Binding of Isaac: Repentance') or line.startswith(
                'Binding of Isaac: Afterbirth') or line.startswith(
                    'Binding of Isaac: Rebirth'):
            self.__parse_version_number(line)
        if line.startswith('RNG Start Seed:') and line != self.seedline:
            self.__parse_seed(line, line_number)
            self.greedmode = 0
        if self.opt.game_version == "Repentance" and line.startswith(
                'Level::Init'
        ) and self.greedmode == 0:  # Store the line of the first floor in Repentance because we can detect if we are in greed mode only after this line in the log
            self.first_line = line
        elif line.startswith('Level::Init'):
            self.__parse_floor(line, line_number)
        if line.startswith('Room'):
            self.__parse_room(line)
            # Detect if we're in Greed mode or not in Repentance. We must do a ton of hacky things to show the first floor with curses because we can't detect greed mode in one line anymore
            if self.opt.game_version == "Repentance":
                greed_mode_starting_rooms = (
                    '1.1000', '1.1010', '1.1011', '1.1012', '1.1013', '1.1014',
                    '1.1015', '1.1016', '1.1017', '1.1018', '1.2000', '1.2001',
                    '1.2002', '1.2003', '1.2004', '1.2005', '1.2006', '1.2007',
                    '1.2008', '1.2009', '1.3000', '1.3001', '1.3002', '1.3003',
                    '1.3004', '1.3005', '1.3006', '1.3007', '1.3008', '1.3009',
                    '1.3010', '1.4000', '1.4001', '1.4002', '1.4003', '1.4004',
                    '1.4005', '1.4006', '1.4007', '1.4008', '1.4009', '1.4010',
                    '1.5000', '1.5001', '1.5002', '1.5003', '1.5004', '1.5005',
                    '1.5006', '1.5007', '1.5008', '1.5009', '1.5010', '1.6000',
                    '1.6001', '1.6002', '1.6003', '1.6004', '1.6005', '1.6006',
                    '1.6007', '1.6008', '1.6009')
                match = re.search(r"Room (.+?)\(", line)
                room_id = match.group(1)
                if room_id == '18.1000':
                    self.state.item_list = []
                elif room_id in greed_mode_starting_rooms and self.greedmode == 0:
                    self.greedmode = 2
                    self.__parse_floor(self.first_line, line_number)
                    self.__parse_curse(self.curse_first_floor)
                elif self.greedmode == 0 or room_id == '5.50000':  # 5.5000 is Mega Satan's Room in Challenge #31
                    if line.startswith('Room 5.5000'):
                        self.backasswards = True
                    else:
                        self.backasswards = False
                    self.greedmode = 1
                    self.__parse_floor(self.first_line, line_number)
                    self.__parse_curse(self.curse_first_floor)
        if line.endswith(
                'Subtype 8'
        ):  # This is to detect if we play as Lazarus to avoid showing two Anemics in the tracker
            self.lazarus = True
        else:  # To reset self.lazarus if we don't play it because it would never add Anemic again unless you close/re-open the game
            for i in range(20):
                if i != 8 and line.endswith("Subtype " + str(i)):
                    self.lazarus = False
        if line.startswith("Curse"):
            self.__parse_curse(line)
        if line.startswith("Spawn co-player!"):
            self.spawned_coop_baby = line_number + self.seek
        if re.search(r"Added \d+ Collectibles", line):
            self.log.debug("Reroll detected!")
            self.state.reroll()
        if line.startswith('Adding collectible '):
            self.__parse_item_add(line_number, line)
        if line.startswith('Gulping trinket ') or line.startswith(
                'Adding smelted trinket '):
            self.__parse_trinket_gulp(line)
        if line.startswith('Removing collectible ') or line.startswith(
                'Removing voided collectible ') or line.startswith(
                    'Removing smelted trinket '):
            self.__parse_item_remove(line)
        if line.startswith('Executing command: reseed'):
            # racing+ re-generates floors if they contain duplicate rooms. we need to track that this is happening
            # so we don't erroneously think the entire run is being restarted when it happens on b1.
            self.reseeding_floor = True

    def __trigger_new_run(self, line_number):
        self.log.debug("Starting new run, seed: %s", self.current_seed)
        self.run_start_line = line_number + self.seek
        self.state.reset(self.current_seed, Options().game_version)

    def __parse_version_number(self, line):
        words = line.split()
        self.state.version_number = words[-1]

    def __parse_seed(self, line, line_number):
        """ Parse a seed line """
        # This assumes a fixed width, but from what I see it seems safe
        self.current_seed = line[16:25]
        self.seedline = line  # In Repentance if you Save&Quit, the RNG Start Seed line will happen again so we need to store the whole line to not trigger the function if this line is duplicated
        self.__trigger_new_run(line_number)

        # Antibirth doesn't have a proper way to detect run resets
        # it will wipe the tracker when doing a "continue"
        if self.opt.game_version == "Antibirth":
            self.__trigger_new_run(line_number)

    def __parse_room(self, line):
        """ Parse a room line """
        if 'Start Room' not in line:
            self.getting_start_items = False

        match = re.search(r"Room (.+?)\(", line)
        if match:
            room_id = match.group(1)
            self.state.change_room(room_id)

    def __parse_floor(self, line, line_number):
        """ Parse the floor in line and push it to the state """
        # Create a floor tuple with the floor id and the alternate id
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance":
            regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)"
        elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth":
            regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)"
        else:
            return
        search_result = re.search(regexp_str, line)
        if search_result is None:
            self.log.debug(
                "log.txt line doesn't match expected regex\nline: \"" + line +
                "\"\nregex:\"" + regexp_str + "\"")
            return

        floor = int(search_result.group(1))
        alt = search_result.group(2)
        self.getting_start_items = True

        # we use generation of the first floor as our trigger that a new run started.
        # in racing+, it doesn't count if the game is currently in the process of "reseeding" that floor.
        # in antibirth, this doesn't work at all; instead we have to use the seed being printed as our trigger.
        # that means if you s+q in antibirth, it resets the tracker.
        # In Repentance, Downpour 1 and Dross 1 are considered Stage 1.
        # So we need to add a condition to avoid tracker reseting when entering those floors.
        # In Repentance, don't trigger a new run on floor 1 because of the R Key item
        if self.reseeding_floor:
            self.reseeding_floor = False
        elif floor == 1 and self.opt.game_version != "Antibirth" and self.opt.game_version != "Repentance" and self.backasswards == False:
            self.__trigger_new_run(line_number)

        # Special handling for the Cathedral and The Chest and Afterbirth
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+" or self.opt.game_version == "Repentance":
            self.log.debug("floor")
            # In Afterbirth, Cath is an alternate of Sheol (which is 10)
            # and Chest is an alternate of Dark Room (which is 11)
            # In Repentance, alt paths are same stage as their counterparts (ex: Basement 1 = Downpour 1)
            if alt == '4' or alt == '5':
                floor += 15
            elif floor == 10 and alt == '0':
                floor -= 1
            elif floor == 11 and alt == '1':
                floor += 1
            elif floor == 9:
                floor = 13
            elif floor == 12:
                floor = 14
            elif floor == 13:
                floor = 15
        else:
            # In Rebirth, floors have different numbers
            if alt == '1' and (floor == 9 or floor == 11):
                floor += 1
        floor_id = 'f' + str(floor)

        # Greed mode
        if (alt == '3' and self.opt.game_version != "Repentance") or (
                self.opt.game_version == "Repentance" and self.greedmode == 2):
            floor_id += 'g'

        self.state.add_floor(Floor(floor_id))
        return True

    def __parse_curse(self, line):
        """ Parse the curse and add it to the last floor """
        if self.curse_first_floor == "":
            self.curse_first_floor = line
        elif self.greedmode != 0:
            self.curse_first_floor = ""
        if line.startswith(
                "Curse of the Labyrinth!"
        ) or self.curse_first_floor == "Curse of the Labyrinth!":
            self.state.add_curse(Curse.Labyrinth)
        if line.startswith("Curse of Blind"
                           ) or self.curse_first_floor == "Curse of Blind":
            self.state.add_curse(Curse.Blind)
        if line.startswith("Curse of the Lost!"):
            self.state.add_curse(Curse.Lost)

    def __parse_item_add(self, line_number, line):
        """ Parse an item and push it to the state """
        if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek -
                                                      1] == line:
            self.log.debug("Skipped duplicate item line from baby presence")
            return False
        is_Jacob_item = line.endswith(
            "(Jacob)"
        ) and self.opt.game_version == "Repentance"  # The second part of the condition is to avoid showing Jacob's Head if you play on a modded char in AB+
        is_Esau_item = line.endswith(
            "(Esau)"
        ) and self.opt.game_version == "Repentance"  # The second part of the condition is to avoid showing Esau's Head if you play on a modded char in AB+
        end_name = -15 if is_Jacob_item or is_Esau_item else -1
        space_split = line.split(" ")
        numeric_id = space_split[
            2]  # When you pick up an item, this has the form: "Adding collectible 105 (The D6)"
        item_name = " ".join(space_split[3:])[1:end_name]
        item_id = ""

        if int(numeric_id) < 0:
            numeric_id = "-1"
        if int(
                numeric_id
        ) == 577:  # Damocles can't be rerolled by D4 so no need to show it twice
            numeric_id = "656"

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            # it might be a modded custom item. let's see if we recognize the name
            item_id = Item.modded_item_id_prefix + item_name
            if not Item.contains_info(item_id):
                item_id = "NEW"

        self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name)
        if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \
                and self.state.contains_item(item_id):
            self.log.debug("Skipped duplicate item line from baby entry")
            return False

        # It's a blind pickup if we're on a blind floor and we don't have the Black Candle
        blind_pickup = self.state.last_floor.floor_has_curse(
            Curse.Blind) and not self.state.contains_item('260')
        if not (numeric_id == "214" and
                ((self.state.contains_item('214')
                  and self.state.contains_item('332')) or
                 (self.lazarus and self.state.contains_item('214')))):
            added = self.state.add_item(
                Item(item_id,
                     self.state.last_floor,
                     self.getting_start_items,
                     blind=blind_pickup,
                     is_Jacob_item=is_Jacob_item,
                     is_Esau_item=is_Esau_item))
            if not added:
                self.log.debug(
                    "Skipped adding item %s to avoid space-bar duplicate",
                    item_id)
        else:
            self.log.debug(
                "Skipped adding Anemic from Lazarus Rags because we already have Anemic"
            )
        return True

    def __parse_trinket_gulp(self, line):
        """ Parse a (modded) trinket gulp and push it to the state """
        space_split = line.split(" ")
        # When using a mod like racing+ on AB+, a trinket gulp has the form: "Gulping trinket 10"
        # In Repentance, a gulped trinket has the form : "Adding smelted trinket 10"
        if self.opt.game_version == "Repentance":
            numeric_id = str(
                int(space_split[3]) + 2000
            )  # the tracker hackily maps trinkets to items 2000 and up.
        else:
            numeric_id = str(
                int(space_split[2]) + 2000
            )  # the tracker hackily maps trinkets to items 2000 and up.
        is_Jacob_item = line.endswith(
            "(Jacob)") and self.opt.game_version == "Repentance"
        is_Esau_item = line.endswith(
            "(Esau)") and self.opt.game_version == "Repentance"

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            item_id = "NEW"

        self.log.debug("Gulped trinket: %s", item_id)

        added = self.state.add_item(
            Item(item_id,
                 self.state.last_floor,
                 self.getting_start_items,
                 is_Jacob_item=is_Jacob_item,
                 is_Esau_item=is_Esau_item))
        if not added:
            self.log.debug(
                "Skipped adding item %s to avoid space-bar duplicate", item_id)
        return True

    def __parse_item_remove(self, line):
        """ Parse an item and remove it from the state """
        space_split = line.split(" ")  # Hacky string manipulation
        # When you lose an item, this has the form: "Removing collectible 105 (The D6)" or "Removing voided collectible 105 (The D6)"
        if self.opt.game_version == "Repentance":
            item_id = space_split[3]
            if space_split[2] == "trinket":
                item_id = str(int(space_split[3]) + 2000)
        else:
            item_id = space_split[2]
        item_name = " ".join(space_split[3:])[1:-1]
        # Check if the item ID exists
        if Item.contains_info(item_id):
            removal_id = item_id
        else:
            # that means it's probably a custom item
            removal_id = Item.modded_item_id_prefix + item_name

        self.log.debug("Removed item. id: %s", removal_id)

        # A check will be made inside the remove_item function
        # to see if this item is actually in our inventory or not.
        return self.state.remove_item(removal_id)

    def __load_log_file(self):
        if self.log_file_path is None:
            return False

        if self.log_file_handle is None:
            self.log_file_handle = open(self.log_file_path, 'r')

        cached_length = len(self.content)
        file_size = os.path.getsize(self.log_file_path)

        if cached_length > file_size or cached_length == 0:  # New log file or first time loading the log
            self.reset()
            self.content = open(self.log_file_path, 'r').read()
        elif cached_length < file_size:  # Append existing content
            self.log_file_handle.seek(cached_length + 1)
            self.content += self.log_file_handle.read()
        return True
class LogParser(object):
    """
    This class load Isaac's log file, and incrementally modify a state representing this log
    """
    def __init__(self, prefix, tracker_version):
        self.state = TrackerState("", tracker_version, Options().game_version)
        self.log = logging.getLogger("tracker")
        self.file_prefix = prefix

        self.reset()

        # FIXME run summary.
        self.run_ended = 0

    def reset(self):
        """Reset variable specific to the log file/run"""
        # Variables describing the parser state
        self.getting_start_items = False
        self.current_room = ""
        self.current_seed = ""
        # Cached contents of log
        self.content = ""
        # Log split into lines
        self.splitfile = []
        self.run_start_line = 0
        self.seek = 0
        self.spawned_coop_baby = 0
        self.state.reset(self.current_seed, Options().game_version)

    def parse(self):
        """
        Parse the log file and return a TrackerState object,
        or None if the log file couldn't be found
        """

        self.opt = Options()
        # Attempt to load log_file
        if not self.__load_log_file():
            return None
        self.splitfile = self.content.splitlines()

        self.getting_start_items = False  # This will become true if we are getting starting items

        # Process log's new output
        for current_line_number, line in enumerate(self.splitfile[self.seek:]):
            self.__parse_line(current_line_number, line)

        self.seek = len(self.splitfile)
        return self.state

    def __parse_line(self, line_number, line):
        """
        Parse a line using the (line_number, line) tuple
        """
        #in afterbirth+, all lines start with this. we want to slice it off.
        info_prefix = '[INFO] - '
        if line.startswith(info_prefix):
            line = line[len(info_prefix):]

        # Check and handle the end of the run; the order is important
        # - we want it after boss kill but before "RNG Start Seed"
        self.__check_end_run(line_number + self.seek, line)

        if line.startswith('RNG Start Seed:'):
            self.__parse_seed(line, line_number)
        if line.startswith('Room'):
            self.__parse_room(line)
        if line.startswith('Level::Init'):
            self.__parse_floor(line)
        if line.startswith("Curse"):
            self.__parse_curse(line)
        if line.startswith("Spawn co-player!"):
            self.spawned_coop_baby = line_number + self.seek
        if re.search(r"Added \d+ Collectibles", line):
            self.log.debug("Reroll detected!")
            self.state.reroll()
        if line.startswith('Adding collectible'):
            self.__parse_item(line_number, line)

    def __parse_seed(self, line, line_number):
        """ Parse a seed line """
        # This assumes a fixed width, but from what I see it seems safe
        self.current_seed = line[16:25]

        # when we see a new seed, that means it's a new run
        self.log.debug("Starting new run, seed: %s", self.current_seed)
        self.run_start_line = line_number + self.seek
        self.state.reset(self.current_seed, Options().game_version)
        self.run_ended = False

    def __parse_room(self, line):
        """ Parse a room line """
        if 'Start Room' not in line:
            self.getting_start_items = False

    def __parse_floor(self, line):
        """ Parse the floor in line and push it to the state """
        # Create a floor tuple with the floor id and the alternate id
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+":
            regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)"
        elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth":
            regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)"
        else:
            return
        floor_tuple = tuple(
            [re.search(regexp_str, line).group(x) for x in [1, 2]])

        self.getting_start_items = True
        # Assume floors aren't cursed until we see they are
        floor = int(floor_tuple[0])
        alt = floor_tuple[1]

        # Special handling for cath and chest and Afterbirth
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+":
            # In Afterbirth Cath is an alternate of Sheol (which is 10)
            # and Chest is an alternate of Dark room (which is 11)
            if floor == 10 and alt == '0':
                floor -= 1
            elif floor == 11 and alt == '1':
                floor += 1
        else:
            # In Rebirth floors have different numbers
            if alt == '1' and (floor == 9 or floor == 11):
                floor += 1
        floor_id = 'f' + str(floor)
        # Greed mode
        if alt == '3':
            floor_id += 'g'

        self.state.add_floor(Floor(floor_id))
        return True

    def __parse_curse(self, line):
        """ Parse the curse and add it to the last floor """
        if line.startswith("Curse of the Labyrinth!"):
            self.state.add_curse(Curse.Labyrinth)
        if line.startswith("Curse of Blind"):
            self.state.add_curse(Curse.Blind)
        if line.startswith("Curse of the Lost!"):
            self.state.add_curse(Curse.Lost)

    def __parse_item(self, line_number, line):
        """ Parse an Item and push it to the state """
        if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek -
                                                      1] == line:
            self.log.debug("Skipped duplicate item line from baby presence")
            return False
        space_split = line.split(" ")  # Hacky string manipulation
        item_id = space_split[
            2]  # A string has the form of "Adding collectible 105 (The D6)"

        # Check if the item ID exists
        if not Item.contains_info(item_id):
            item_id = "NEW"

        item_name = " ".join(space_split[3:])[1:-1]
        self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name)
        if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \
                and self.state.contains_item(item_id):
            self.log.debug("Skipped duplicate item line from baby entry")
            return False
        #it's a blind pickup if we're on a blind floor and they don't have the black candle
        blind_pickup = self.state.last_floor.floor_has_curse(
            Curse.Blind) and not self.state.contains_item('260')
        added = self.state.add_item(
            Item(item_id,
                 self.state.last_floor,
                 self.getting_start_items,
                 blind=blind_pickup))
        if not added:
            self.log.debug(
                "Skipped adding item %s to avoid space-bar duplicate", item_id)
        return True

    def __load_log_file(self):
        """
        Attempt to load log file from common location.
        Return true if successfully loaded, false otherwise
        """
        path = None
        logfile_location = ""
        version_path_fragment = self.opt.game_version
        if version_path_fragment == "Antibirth":
            version_path_fragment = "Rebirth"

        if platform.system() == "Windows":
            logfile_location = os.environ[
                'USERPROFILE'] + '/Documents/My Games/Binding of Isaac {}/'
        elif platform.system() == "Linux":
            logfile_location = os.getenv(
                'XDG_DATA_HOME',
                os.path.expanduser('~') +
                '/.local/share') + '/binding of isaac {}/'
            version_path_fragment = version_path_fragment.lower()
        elif platform.system() == "Darwin":
            logfile_location = os.path.expanduser(
                '~') + '/Library/Application Support/Binding of Isaac {}/'

        logfile_location = logfile_location.format(version_path_fragment)

        for check in (self.file_prefix + '../log.txt',
                      logfile_location + 'log.txt'):
            if os.path.isfile(check):
                path = check
                break
        if path is None:
            return False

        cached_length = len(self.content)
        file_size = os.path.getsize(path)
        if cached_length > file_size or cached_length == 0:  # New log file or first time loading the log
            self.reset()
            self.content = open(path, 'rb').read()
        elif cached_length < file_size:  # Append existing content
            f = open(path, 'rb')
            f.seek(cached_length + 1)
            self.content += f.read()
        return True

# Above are legacy method for handling end of run

    def __check_end_run(self, line_number, line):
        if not self.run_ended:
            died_to = ""
            end_type = ""
            # FIXME right now I don't think boss detection in the log is working properly
            if self.state.last_boss and self.state.last_boss[0] in [
                    '???', 'The Lamb', 'Mega Satan'
            ]:
                end_type = "Won"
            elif (self.state.seed !=
                  '') and line.startswith('RNG Start Seed:'):
                end_type = "Reset"
            elif line.startswith('Game Over.'):
                end_type = "Death"
                died_to = re.search(r'(?i)Killed by \((.*)\) spawned',
                                    line).group(1)
            if end_type:
                last_run = {
                    "bosses": self.state.bosses,
                    "items": self.state.item_list,
                    "seed": self.state.seed,
                    "died_to": died_to,
                    "end_type": end_type
                }
                self.run_ended = True
                self.log.debug("End of Run! %s", last_run)
                if end_type != "Reset":
                    self.__save_file(self.run_start_line, line_number,
                                     last_run)

    def __save_file(self, start, end, last_run):
        dir_name = self.file_prefix + "run_logs"
        if not os.path.isdir(dir_name):
            os.mkdir(dir_name)
        timestamp = int(time.time())
        seed = self.state.seed.replace(" ", "")
        data = "\n".join(self.splitfile[start:end + 1])
        # FIXME improve
        data = "%s\nRUN_OVER_LINE\n%s" % (data, last_run)
        run_name = "%s%s.log" % (seed, timestamp)
        in_memory_file = StringIO.StringIO()
        with zipfile.ZipFile(in_memory_file,
                             mode='w',
                             compression=zipfile.ZIP_DEFLATED) as zf:
            zf.writestr(run_name, data)
        with open(self.file_prefix + "run_logs/" + run_name + ".zip",
                  "wb") as f:
            f.write(in_memory_file.getvalue())
class LogParser(object):
    """
    This class load Isaac's log file, and incrementally modify a state representing this log
    """
    def __init__(self, prefix, tracker_version, log_finder):
        self.state = TrackerState("", tracker_version, Options().game_version)
        self.log = logging.getLogger("tracker")
        self.wdir_prefix = prefix
        self.log_finder = log_finder

        self.reset()

    def reset(self):
        """Reset variable specific to the log file/run"""
        # Variables describing the parser state
        self.getting_start_items = False
        self.current_room = ""
        self.current_seed = ""
        # Cached contents of log
        self.content = ""
        # Log split into lines
        self.splitfile = []
        self.run_start_line = 0
        self.seek = 0
        self.spawned_coop_baby = 0
        self.log_file_handle = None
        # if they switched between rebirth and afterbirth, the log file we use could change
        self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix)
        self.state.reset(self.current_seed, Options().game_version)

    def parse(self):
        """
        Parse the log file and return a TrackerState object,
        or None if the log file couldn't be found
        """

        self.opt = Options()
        # Attempt to load log_file
        if not self.__load_log_file():
            return None
        self.splitfile = self.content.splitlines()

        # This will become true if we are getting starting items
        self.getting_start_items = False

        # Process log's new output
        for current_line_number, line in enumerate(self.splitfile[self.seek:]):
            self.__parse_line(current_line_number, line)

        self.seek = len(self.splitfile)
        return self.state

    def __parse_line(self, line_number, line):
        """
        Parse a line using the (line_number, line) tuple
        """
        # In Afterbirth+, nearly all lines start with this.
        # We want to slice it off.
        info_prefix = '[INFO] - '
        if line.startswith(info_prefix):
            line = line[len(info_prefix):]

        # Messages printed by mods have this prefix.
        # strip it, so mods can spoof actual game log messages to us if they want to
        luadebug_prefix = 'Lua Debug: '
        if line.startswith(luadebug_prefix):
            line = line[len(luadebug_prefix):]

        # AB and AB+ version messages both start with this text (AB+ has a + at the end)
        if line.startswith('Binding of Isaac: Afterbirth'):
            self.__parse_version_number(line)
        if line.startswith('Binding of Isaac: Rebirth'):
            self.__parse_version_number(line)
        if line.startswith('RNG Start Seed:'):
            self.__parse_seed(line, line_number)
        if line.startswith('Room'):
            self.__parse_room(line)
        if line.startswith('Level::Init'):
            self.__parse_floor(line, line_number)
        if line.startswith("Curse"):
            self.__parse_curse(line)
        if line.startswith("Spawn co-player!"):
            self.spawned_coop_baby = line_number + self.seek
        if re.search(r"Added \d+ Collectibles", line):
            self.log.debug("Reroll detected!")
            self.state.reroll()
        if line.startswith('Adding collectible '):
            self.__parse_item_add(line_number, line)
        if line.startswith('Gulping trinket '):
            self.__parse_trinket_gulp(line)
        if line.startswith('Removing collectible '):
            self.__parse_item_remove(line)

    def __trigger_new_run(self, line_number):
        self.log.debug("Starting new run, seed: %s", self.current_seed)
        self.run_start_line = line_number + self.seek
        self.state.reset(self.current_seed, Options().game_version)

    def __parse_version_number(self, line):
        words = line.split()
        self.state.version_number = words[-1]

    def __parse_seed(self, line, line_number):
        """ Parse a seed line """
        # This assumes a fixed width, but from what I see it seems safe
        self.current_seed = line[16:25]

        # Antibirth doesn't have a proper way to detect run resets
        # it will wipe the tracker when doing a "continue"
        if self.opt.game_version == "Antibirth":
            self.__trigger_new_run(line_number)

    def __parse_room(self, line):
        """ Parse a room line """
        if 'Start Room' not in line:
            self.getting_start_items = False

        match = re.search(r"Room (.+?)\(", line)
        if match:
            room_id = match.group(1)
            self.state.change_room(room_id)

    def __parse_floor(self, line, line_number):
        """ Parse the floor in line and push it to the state """
        # Create a floor tuple with the floor id and the alternate id
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+":
            regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)"
        elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth":
            regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)"
        else:
            return
        search_result = re.search(regexp_str, line)
        if search_result is None:
            self.log.debug(
                "log.txt line doesn't match expected regex\nline: \"" + line +
                "\"\nregex:\"" + regexp_str + "\"")
            return

        floor = int(search_result.group(1))
        alt = search_result.group(2)
        self.getting_start_items = True

        # we use generation of the first floor as our trigger that a new run started.
        # in antibirth, this doesn't work; instead we have to use the seed being printed as our trigger
        # that means if you s+q in antibirth, it resets the tracker.
        if floor == 1 and self.opt.game_version != "Antibirth":
            self.__trigger_new_run(line_number)

        # Special handling for the Cathedral and The Chest and Afterbirth
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+":
            # In Afterbirth, Cath is an alternate of Sheol (which is 10)
            # and Chest is an alternate of Dark Room (which is 11)
            if floor == 10 and alt == '0':
                floor -= 1
            elif floor == 11 and alt == '1':
                floor += 1
        else:
            # In Rebirth, floors have different numbers
            if alt == '1' and (floor == 9 or floor == 11):
                floor += 1
        floor_id = 'f' + str(floor)

        # Greed mode
        if alt == '3':
            floor_id += 'g'

        self.state.add_floor(Floor(floor_id))
        return True

    def __parse_curse(self, line):
        """ Parse the curse and add it to the last floor """
        if line.startswith("Curse of the Labyrinth!"):
            self.state.add_curse(Curse.Labyrinth)
        if line.startswith("Curse of Blind"):
            self.state.add_curse(Curse.Blind)
        if line.startswith("Curse of the Lost!"):
            self.state.add_curse(Curse.Lost)

    def __parse_item_add(self, line_number, line):
        """ Parse an item and push it to the state """
        if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek -
                                                      1] == line:
            self.log.debug("Skipped duplicate item line from baby presence")
            return False
        space_split = line.split(" ")
        numeric_id = space_split[
            2]  # When you pick up an item, this has the form: "Adding collectible 105 (The D6)"
        item_name = " ".join(space_split[3:])[1:-1]
        item_id = ""

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            # it might be a modded custom item. let's see if we recognize the name
            item_id = Item.modded_item_id_prefix + item_name
            if not Item.contains_info(item_id):
                item_id = "NEW"

        self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name)
        if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \
                and self.state.contains_item(item_id):
            self.log.debug("Skipped duplicate item line from baby entry")
            return False

        # It's a blind pickup if we're on a blind floor and we don't have the Black Candle
        blind_pickup = self.state.last_floor.floor_has_curse(
            Curse.Blind) and not self.state.contains_item('260')
        added = self.state.add_item(
            Item(item_id,
                 self.state.last_floor,
                 self.getting_start_items,
                 blind=blind_pickup))
        if not added:
            self.log.debug(
                "Skipped adding item %s to avoid space-bar duplicate", item_id)
        return True

    def __parse_trinket_gulp(self, line):
        """ Parse a (modded) trinket gulp and push it to the state """
        space_split = line.split(" ")
        # When using a mod like racing+, a trinket gulp has the form: "Gulping trinket 10"
        numeric_id = str(
            int(space_split[2]) +
            2000)  # the tracker hackily maps trinkets to items 2000 and up.

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            item_id = "NEW"

        self.log.debug("Gulped trinket: %s", item_id)

        added = self.state.add_item(
            Item(item_id, self.state.last_floor, self.getting_start_items))
        if not added:
            self.log.debug(
                "Skipped adding item %s to avoid space-bar duplicate", item_id)
        return True

    def __parse_item_remove(self, line):
        """ Parse an item and remove it from the state """
        space_split = line.split(" ")  # Hacky string manipulation
        item_id = space_split[
            2]  # When you lose an item, this has the form: "Removing collectible 105 (The D6)"
        item_name = " ".join(space_split[3:])[1:-1]

        # Check if the item ID exists
        if Item.contains_info(item_id):
            removal_id = item_id
        else:
            # that means it's probably a custom item
            removal_id = Item.modded_item_id_prefix + item_name

        self.log.debug("Removed item. id: %s", removal_id)

        # A check will be made inside the remove_item function
        # to see if this item is actually in our inventory or not.
        return self.state.remove_item(removal_id)

    def __load_log_file(self):
        if self.log_file_path is None:
            return False

        if self.log_file_handle is None:
            self.log_file_handle = open(self.log_file_path, 'rb')

        cached_length = len(self.content)
        file_size = os.path.getsize(self.log_file_path)

        if cached_length > file_size or cached_length == 0:  # New log file or first time loading the log
            self.reset()
            self.content = open(self.log_file_path, 'rb').read()
        elif cached_length < file_size:  # Append existing content
            self.log_file_handle.seek(cached_length + 1)
            self.content += self.log_file_handle.read()
        return True
Example #11
0
class LogParser(object):
    """
    This class loads Isaac's log file, and incrementally modify a state representing this log
    """
    def __init__(self, prefix, tracker_version, log_finder):
        self.state = TrackerState("", tracker_version, Options().game_version)
        self.log = logging.getLogger("tracker")
        self.wdir_prefix = prefix
        self.log_finder = log_finder

        self.reset()

    def reset(self):
        """Reset variable specific to the log file/run"""
        # Variables describing the parser state
        self.getting_start_items = False
        self.reseeding_floor = False
        self.current_room = ""
        self.current_seed = ""
        # Cached contents of log
        self.content = ""
        # Log split into lines
        self.splitfile = []
        self.run_start_line = 0
        self.seek = 0
        self.spawned_coop_baby = 0
        self.log_file_handle = None
        # if they switched between rebirth and afterbirth, the log file we use could change
        self.log_file_path = self.log_finder.find_log_file(self.wdir_prefix)
        self.state.reset(self.current_seed, Options().game_version)

    def parse(self):
        """
        Parse the log file and return a TrackerState object,
        or None if the log file couldn't be found
        """

        self.opt = Options()
        # Attempt to load log_file
        if not self.__load_log_file():
            return None
        self.splitfile = self.content.splitlines()

        # This will become true if we are getting starting items
        self.getting_start_items = False

        # Process log's new output
        for current_line_number, line in enumerate(self.splitfile[self.seek:]):
            self.__parse_line(current_line_number, line)


        self.seek = len(self.splitfile)
        return self.state

    def __parse_line(self, line_number, line):
        """
        Parse a line using the (line_number, line) tuple
        """
        # In Afterbirth+, nearly all lines start with this.
        # We want to slice it off.
        info_prefix = '[INFO] - '
        if line.startswith(info_prefix):
            line = line[len(info_prefix):]

        # Messages printed by mods have this prefix.
        # strip it, so mods can spoof actual game log messages to us if they want to
        luadebug_prefix ='Lua Debug: '
        if line.startswith(luadebug_prefix):
            line = line[len(luadebug_prefix):]

        # AB and AB+ version messages both start with this text (AB+ has a + at the end)
        if line.startswith('Binding of Isaac: Afterbirth'):
            self.__parse_version_number(line)
        if line.startswith('Binding of Isaac: Rebirth'):
            self.__parse_version_number(line)
        if line.startswith('RNG Start Seed:'):
            self.__parse_seed(line, line_number)
        if line.startswith('Room'):
            self.__parse_room(line)
        if line.startswith('Level::Init'):
            self.__parse_floor(line, line_number)
        if line.startswith("Curse"):
            self.__parse_curse(line)
        if line.startswith("Spawn co-player!"):
            self.spawned_coop_baby = line_number + self.seek
        if re.search(r"Added \d+ Collectibles", line):
            self.log.debug("Reroll detected!")
            self.state.reroll()
        if line.startswith('Adding collectible '):
            self.__parse_item_add(line_number, line)
        if line.startswith('Gulping trinket '):
            self.__parse_trinket_gulp(line)
        if line.startswith('Removing collectible '):
            self.__parse_item_remove(line)
        if line.startswith('Executing command: reseed'):
            # racing+ re-generates floors if they contain duplicate rooms. we need to track that this is happening
            # so we don't erroneously think the entire run is being restarted when it happens on b1.
            self.reseeding_floor = True
        

    def __trigger_new_run(self, line_number):
        self.log.debug("Starting new run, seed: %s", self.current_seed)
        self.run_start_line = line_number + self.seek
        self.state.reset(self.current_seed, Options().game_version)

    def __parse_version_number(self, line):
        words = line.split()
        self.state.version_number = words[-1]

    def __parse_seed(self, line, line_number):
        """ Parse a seed line """
        # This assumes a fixed width, but from what I see it seems safe
        self.current_seed = line[16:25]

        # Antibirth doesn't have a proper way to detect run resets
        # it will wipe the tracker when doing a "continue"
        if self.opt.game_version == "Antibirth":
            self.__trigger_new_run(line_number)

    def __parse_room(self, line):
        """ Parse a room line """
        if 'Start Room' not in line:
            self.getting_start_items = False

        match = re.search(r"Room (.+?)\(", line)
        if match:
            room_id = match.group(1)
            self.state.change_room(room_id)

    def __parse_floor(self, line, line_number):
        """ Parse the floor in line and push it to the state """
        # Create a floor tuple with the floor id and the alternate id
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+":
            regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)"
        elif self.opt.game_version == "Rebirth" or self.opt.game_version == "Antibirth":
            regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)"
        else:
            return
        search_result = re.search(regexp_str, line)
        if search_result is None:
            self.log.debug("log.txt line doesn't match expected regex\nline: \"" + line+ "\"\nregex:\"" + regexp_str + "\"")
            return
        
        floor = int(search_result.group(1))
        alt = search_result.group(2)
        self.getting_start_items = True

        # we use generation of the first floor as our trigger that a new run started.
        # in racing+, it doesn't count if the game is currently in the process of "reseeding" that floor.
        # in antibirth, this doesn't work at all; instead we have to use the seed being printed as our trigger.
        # that means if you s+q in antibirth, it resets the tracker.
        if self.reseeding_floor:
            self.reseeding_floor = False
        elif floor == 1 and self.opt.game_version != "Antibirth":
            self.__trigger_new_run(line_number)

        # Special handling for the Cathedral and The Chest and Afterbirth
        if self.opt.game_version == "Afterbirth" or self.opt.game_version == "Afterbirth+":
            # In Afterbirth, Cath is an alternate of Sheol (which is 10)
            # and Chest is an alternate of Dark Room (which is 11)
            if floor == 10 and alt == '0':
                floor -= 1
            elif floor == 11 and alt == '1':
                floor += 1
        else:
            # In Rebirth, floors have different numbers
            if alt == '1' and (floor == 9 or floor == 11):
                floor += 1
        floor_id = 'f' + str(floor)

        # Greed mode
        if alt == '3':
            floor_id += 'g'

        self.state.add_floor(Floor(floor_id))
        return True

    def __parse_curse(self, line):
        """ Parse the curse and add it to the last floor """
        if line.startswith("Curse of the Labyrinth!"):
            self.state.add_curse(Curse.Labyrinth)
        if line.startswith("Curse of Blind"):
            self.state.add_curse(Curse.Blind)
        if line.startswith("Curse of the Lost!"):
            self.state.add_curse(Curse.Lost)

    def __parse_item_add(self, line_number, line):
        """ Parse an item and push it to the state """
        if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line:
            self.log.debug("Skipped duplicate item line from baby presence")
            return False
        space_split = line.split(" ")
        numeric_id = space_split[2] # When you pick up an item, this has the form: "Adding collectible 105 (The D6)"
        item_name = " ".join(space_split[3:])[1:-1]
        item_id = ""

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            # it might be a modded custom item. let's see if we recognize the name
            item_id = Item.modded_item_id_prefix + item_name
            if not Item.contains_info(item_id):
                item_id = "NEW"

        self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name)
        if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \
                and self.state.contains_item(item_id):
            self.log.debug("Skipped duplicate item line from baby entry")
            return False

        # It's a blind pickup if we're on a blind floor and we don't have the Black Candle
        blind_pickup = self.state.last_floor.floor_has_curse(Curse.Blind) and not self.state.contains_item('260')
        added = self.state.add_item(Item(item_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup))
        if not added:
            self.log.debug("Skipped adding item %s to avoid space-bar duplicate", item_id)
        return True

    def __parse_trinket_gulp(self, line):
        """ Parse a (modded) trinket gulp and push it to the state """
        space_split = line.split(" ")
        # When using a mod like racing+, a trinket gulp has the form: "Gulping trinket 10"
        numeric_id = str(int(space_split[2]) + 2000) # the tracker hackily maps trinkets to items 2000 and up.

        # Check if we recognize the numeric id
        if Item.contains_info(numeric_id):
            item_id = numeric_id
        else:
            item_id = "NEW"

        self.log.debug("Gulped trinket: %s", item_id)

        added = self.state.add_item(Item(item_id, self.state.last_floor, self.getting_start_items))
        if not added:
            self.log.debug("Skipped adding item %s to avoid space-bar duplicate", item_id)
        return True

    def __parse_item_remove(self, line):
        """ Parse an item and remove it from the state """
        space_split = line.split(" ") # Hacky string manipulation
        item_id = space_split[2] # When you lose an item, this has the form: "Removing collectible 105 (The D6)"
        item_name = " ".join(space_split[3:])[1:-1]

        # Check if the item ID exists
        if Item.contains_info(item_id):
            removal_id = item_id
        else:
            # that means it's probably a custom item
            removal_id = Item.modded_item_id_prefix + item_name

        self.log.debug("Removed item. id: %s", removal_id)

        # A check will be made inside the remove_item function
        # to see if this item is actually in our inventory or not.
        return self.state.remove_item(removal_id)




    def __load_log_file(self):
        if self.log_file_path is None:
            return False

        if self.log_file_handle is None:
            self.log_file_handle = open(self.log_file_path, 'r')

        cached_length = len(self.content)
        file_size = os.path.getsize(self.log_file_path)

        if cached_length > file_size or cached_length == 0: # New log file or first time loading the log
            self.reset()
            self.content = open(self.log_file_path, 'r').read()
        elif cached_length < file_size:  # Append existing content
            self.log_file_handle.seek(cached_length + 1)
            self.content += self.log_file_handle.read()
        return True
    def run(self):
        """ The main routine which controls everything """
        framecount = 0

        # Create drawing tool to use to draw everything - it'll create its own screen
        drawing_tool = DrawingTool(wdir_prefix)
        drawing_tool.set_window_title_info(update_notifier=(" v" + self.tracker_version))
        opt = Options()

        parser = LogParser(wdir_prefix, self.tracker_version, LogFinder())

        event_result = None
        state = None
        custom_title_enabled = opt.custom_title_enabled
        read_from_server = opt.read_from_server
        write_to_server = opt.write_to_server
        game_version = opt.game_version
        state_version = -1
        twitch_username = None
        new_states_queue = []
        screen_error_message = None
        retry_in = 0
        update_timer = opt.log_file_check_seconds
        last_game_version = None

        while event_result != Event.DONE:
            # Check for events and handle them
            event_result = drawing_tool.handle_events()

            # The user checked or unchecked the "Custom Title Enabled" checkbox
            if opt.custom_title_enabled != custom_title_enabled:
                custom_title_enabled = opt.custom_title_enabled
                drawing_tool.update_window_title()

            # The user started or stopped watching someone from the server (or they started watching a new person from the server)
            if opt.read_from_server != read_from_server or opt.twitch_name != twitch_username:
                twitch_username = opt.twitch_name
                read_from_server = opt.read_from_server
                new_states_queue = []
                # Also restart version count if we go back and forth from log.txt to server
                if read_from_server:
                    state_version = -1
                    state = None
                    # Change the delay for polling, as we probably don't want to fetch it every second
                    update_timer_override = 2
                    # Show who we are watching in the title bar
                    drawing_tool.set_window_title_info(watching=True, watching_player=twitch_username, updates_queued=len(new_states_queue))
                else:
                    drawing_tool.set_window_title_info(watching=False)
                    update_timer_override = 0

            # The user started or stopped broadcasting to the server
            if opt.write_to_server != write_to_server:
                write_to_server = opt.write_to_server
                drawing_tool.set_window_title_info(uploading=opt.write_to_server)

            if opt.game_version != game_version:
                parser.reset()
                game_version = opt.game_version

            # Force refresh state if we updated options or if we need to retry
            # to contact the server.
            if (event_result == Event.OPTIONS_UPDATE or
                (screen_error_message is not None and retry_in == 0)):
                # By setting the framecount to 0 we ensure we'll refresh the state right away
                framecount = 0
                screen_error_message = None
                retry_in = 0
                # Force updates after changing options
                if state is not None:
                    state.modified = True

            # normally we check for updates based on how the option is set
            # when doing network stuff, this can be overridden
            update_delay = opt.log_file_check_seconds
            if update_timer_override != 0:
                update_delay = update_timer_override
                
            # Now we re-process the log file to get anything that might have loaded;
            # do it every update_timer seconds (making sure to truncate to an integer
            # or else it might never mod to 0)
            frames_between_checks = int(Options().framerate_limit * update_delay)
            if frames_between_checks <= 0:
                frames_between_checks = 1
            
            if framecount % frames_between_checks == 0:
                if retry_in != 0:
                    retry_in -= 1
                # Let the parser do his thing and give us a state
                if opt.read_from_server:
                    base_url = opt.trackerserver_url + "/tracker/api/user/" + opt.twitch_name
                    json_dict = None
                    try:
                        json_version = urllib.request.urlopen(base_url + "/version").read()
                        if int(json_version) > state_version:
                            # FIXME better handling of 404 error ?
                            json_state = urllib.request.urlopen(base_url).read()
                            json_dict = json.loads(json_state, "utf-8")
                            new_state = TrackerState.from_json(json_dict)
                            if new_state is None:
                                raise Exception("server gave us empty state")
                            state_version = int(json_version)
                            new_states_queue.append((state_version, new_state))
                            drawing_tool.set_window_title_info(updates_queued=len(new_states_queue))
                    except Exception:
                        state = None
                        log_error("Couldn't load state from server\n" + traceback.format_exc())
                        if json_dict is not None:
                            if "tracker_version" in json_dict:
                                their_version = json_dict["tracker_version"]
                            else:
                                # This is the only version that can upload to the server but doesn't include a version string
                                their_version = "0.10-beta1"

                            if their_version != self.tracker_version:
                                screen_error_message = "They are using tracker version " + their_version + " but you have " + self.tracker_version
                else:
                    force_draw = state and state.modified
                    state = parser.parse()
                    if force_draw:
                        state.modified = True
                    if write_to_server and not opt.trackerserver_authkey:
                        screen_error_message = "Your authkey is blank. Get a new authkey in the options menu and paste it into the authkey text field."
                    if state is not None and write_to_server and state.modified and screen_error_message is None:
                        opener = urllib.request.build_opener(urllib.request.HTTPHandler)
                        put_url = opt.trackerserver_url + "/tracker/api/update/" + opt.trackerserver_authkey
                        json_string = json.dumps(state, cls=TrackerStateEncoder, sort_keys=True)
                        request = urllib.request.Request(put_url,
                                                  data=json_string)
                        request.add_header('Content-Type', 'application/json')
                        request.get_method = lambda: 'PUT'
                        try:
                            result = opener.open(request)
                            result_json = json.loads(result.read())
                            updated_user = result_json["updated_user"]
                            if updated_user is None:
                                screen_error_message = "The server didn't recognize you. Try getting a new authkey in the options menu."
                            else:
                                screen_error_message = None
                        except Exception as e:
                            log_error("ERROR: Couldn't send item info to server\n" + traceback.format_exc())
                            screen_error_message = "ERROR: Couldn't send item info to server, check tracker_log.txt"
                            # Retry to write the state in 10*update_timer (aka 10 sec in write mode)
                            retry_in = 10

            # Check the new state at the front of the queue to see if it's time to use it
            if len(new_states_queue) > 0:
                (state_timestamp, new_state) = new_states_queue[0]
                current_timestamp = int(time.time())
                if current_timestamp - state_timestamp >= opt.read_delay or opt.read_delay == 0 or state is None:
                    state = new_state
                    new_states_queue.pop(0)
                    drawing_tool.set_window_title_info(updates_queued=len(new_states_queue))

            if state is None and screen_error_message is None:
                if read_from_server:
                    screen_error_message = "Unable to read state from server. Please verify your options setup and tracker_log.txt"
                    # Retry to read the state in 5*update_timer (aka 10 sec in read mode)
                    retry_in = 5
                else:
                    screen_error_message = "log.txt for " + opt.game_version + " not found. Make sure you have the right game selected in the options."

            if screen_error_message is not None:
                drawing_tool.write_error_message(screen_error_message)
            else:
                # We got a state, now we draw it
                drawing_tool.draw_state(state)

            # if we're watching someone and they change their game version, it can require us to reset
            if state and last_game_version != state.game_version:
                drawing_tool.reset_options()
                last_game_version = state.game_version

            drawing_tool.tick()
            framecount += 1

        # Main loop finished; program is exiting
        drawing_tool.save_window_position()
        Options().save_options(wdir_prefix + "options.json")
Example #13
0
class LogParser(object):
    """
    This class load Isaac's log file, and incrementally modify a state representing this log
    """
    def __init__(self, prefix, tracker_version):
        self.state = TrackerState("", tracker_version)
        self.log = logging.getLogger("tracker")
        self.file_prefix = prefix

        self.game_version = ""
        self.__reset()

        # FIXME run summary.
        self.run_ended = 0

    def __reset(self):
        """Reset variable specific to the log file/run"""
        # Variables describing the parser state
        self.getting_start_items = False
        self.current_room = ""
        self.current_seed = ""
        # Cached contents of log
        self.content = ""
        # Log split into lines
        self.splitfile = []
        self.run_start_line = 0
        self.seek = 0
        self.spawned_coop_baby = 0



    def parse(self):
        """
        Parse the log file and return a TrackerState object,
        or None if the log file couldn't be found
        """
        # Attempt to load log_file
        if not self.__load_log_file():
            return None
        self.splitfile = self.content.splitlines()

        self.getting_start_items = False # This will become true if we are getting starting items

        # Process log's new output
        for current_line_number, line in enumerate(self.splitfile[self.seek:]):
            self.__parse_line(current_line_number, line)


        self.seek = len(self.splitfile)
        return self.state


    def __parse_line(self, line_number, line):
        """
        Parse a line using the (line_number, line) tuple
        """
        if line.startswith('Mom clear time:'):
            self.__parse_boss(line)

        # Check and handle the end of the run; the order is important
        # - we want it after boss kill but before "RNG Start Seed"
        self.__check_end_run(line_number + self.seek, line)

        if line.startswith('RNG Start Seed:'):
            self.__parse_seed(line)
        if line.startswith('Room'):
            self.__parse_room(line)
        if line.startswith('Level::Init'):
            self.__parse_floor(line_number, line)
        if line.startswith("Curse"):
            self.__parse_curse(line)
        if line.startswith("Spawn co-player!"):
            self.spawned_coop_baby = line_number + self.seek
        if re.search(r"Added \d+ Collectibles", line):
            self.log.debug("Reroll detected!")
            self.state.reroll()
        if line.startswith('Adding collectible'):
            self.__parse_item(line_number, line)


    def __parse_boss(self, line):
        """ Parse a boss line """
        # TODO "Boss %i added to SaveState", Mom:6, It lives:25,
        # Satan/The Lamb:24/54 Isaac/???:39/40, Mega Satan:55
        kill_time = int(line.split(" ")[-1])
        # If you re-enter a room you get a "mom clear time" again,
        # check for that (can you fight the same boss twice?)
        # FIXME right now we only have support for Mom (6)
        self.state.add_boss("6")

    def __parse_seed(self, line):
        """ Parse a seed line """
        # This assumes a fixed width, but from what I see it seems safe
        self.current_seed = line[16:25]

    def __parse_room(self, line):
        """ Parse a room line """
        self.current_room = re.search(r'\((.*)\)', line).group(1)
        if 'Start Room' not in line:
            self.getting_start_items = False
        self.log.debug("Entered room: %s", self.current_room)

    def __parse_floor(self, line_number, line):
        """ Parse the floor in line and push it to the state """
        # Create a floor tuple with the floor id and the alternate id
        if self.game_version == "Afterbirth":
            regexp_str = r"Level::Init m_Stage (\d+), m_StageType (\d+)"
        else:
            regexp_str = r"Level::Init m_Stage (\d+), m_AltStage (\d+)"
        floor_tuple = tuple([re.search(regexp_str, line).group(x) for x in [1, 2]])

        self.getting_start_items = True
        # Assume floors aren't cursed until we see they are
        floor = int(floor_tuple[0])
        alt = floor_tuple[1]

        # Special handling for cath and chest and Afterbirth
        if self.game_version == "Afterbirth":
            # In Afterbirth Cath is an alternate of Sheol (which is 10)
            # and Chest is an alternate of Dark room (which is 11)
            if floor == 10 and alt == '0':
                floor -= 1
            elif floor == 11 and alt == '1':
                floor += 1
        else:
            # In Rebirth floors have different numbers
            if alt == '1' and (floor == 9 or floor == 11):
                floor += 1
        floor_id = 'f' + str(floor)
        # Greed mode
        if alt == '3':
            floor_id += 'g'

        # when we see a new floor 1, that means a new run has started
        if floor == 1:
            self.log.debug("Starting new run, seed: %s", self.current_seed)
            self.run_start_line = line_number + self.seek
            self.state.reset(self.current_seed)
            self.run_ended = False


        self.state.add_floor(Floor(floor_id))
        return True

    def __parse_curse(self, line):
        """ Parse the curse and add it to the last floor """
        if line.startswith("Curse of the Labyrinth!"):
            self.state.add_curse(Curse.Labyrinth)
        if line.startswith("Curse of Blind"):
            self.state.add_curse(Curse.Blind)
        if line.startswith("Curse of the Lost!"):
            self.state.add_curse(Curse.Lost)

    def __parse_item(self, line_number, line):
        """ Parse an Item and push it to the state """
        if len(self.splitfile) > 1 and self.splitfile[line_number + self.seek - 1] == line:
            self.log.debug("Skipped duplicate item line from baby presence")
            return False
        space_split = line.split(" ") # Hacky string manipulation
        item_id = space_split[2] # A string has the form of "Adding collectible 105 (The D6)"

        # Check if the item ID exists
        if not Item.contains_info(item_id):
            item_id = "NEW"

        item_name = " ".join(space_split[3:])[1:-1]
        self.log.debug("Picked up item. id: %s, name: %s", item_id, item_name)
        if ((line_number + self.seek) - self.spawned_coop_baby) < (len(self.state.item_list) + 10) \
                and self.state.contains_item(item_id):
            self.log.debug("Skipped duplicate item line from baby entry")
            return False
        #it's a blind pickup if we're on a blind floor and they don't have the black candle
        blind_pickup = self.state.last_floor.floor_has_curse(Curse.Blind) and not self.state.contains_item('260')
        added = self.state.add_item(Item(item_id, self.state.last_floor, self.getting_start_items, blind=blind_pickup))
        if not added:
            self.log.debug("Skipped adding item %s to avoid space-bar duplicate", item_id)
        return True

    def __load_log_file(self):
        """
        Attempt to load log file from common location.
        Return true if successfully loaded, false otherwise
        """
        path = None
        logfile_location = ""
        game_names = ("Afterbirth", "Rebirth")
        if platform.system() == "Windows":
            logfile_location = os.environ['USERPROFILE'] + '/Documents/My Games/Binding of Isaac {}/'
        elif platform.system() == "Linux":
            logfile_location = os.getenv('XDG_DATA_HOME',
                os.path.expanduser('~') + '/.local/share') + '/binding of isaac {}/'
            game_names = ("afterbirth", "rebirth")
        elif platform.system() == "Darwin":
            logfile_location = os.path.expanduser('~') + '/Library/Application Support/Binding of Isaac {}/'
        if os.path.exists(logfile_location.format(game_names[0])):
            logfile_location = logfile_location.format(game_names[0])
            self.game_version = "Afterbirth"
        else:
            logfile_location = logfile_location.format(game_names[1])
            self.game_version = "Rebirth"

        for check in (self.file_prefix + '../log.txt', logfile_location + 'log.txt'):
            if os.path.isfile(check):
                path = check
                break
        if path is None:
            return False

        cached_length = len(self.content)
        file_size = os.path.getsize(path)
        if cached_length > file_size or cached_length == 0: # New log file or first time loading the log
            self.__reset()
            self.content = open(path, 'rb').read()
        elif cached_length < file_size:  # Append existing content
            f = open(path, 'rb')
            f.seek(cached_length + 1)
            self.content += f.read()
        return True


# Above are legacy method for handling end of run
    def __check_end_run(self, line_number, line):
        if not self.run_ended:
            died_to = ""
            end_type = ""
            # FIXME right now I don't think boss detection in the log is working properly
            if self.state.last_boss and self.state.last_boss[0] in ['???', 'The Lamb', 'Mega Satan']:
                end_type = "Won"
            elif (self.state.seed != '') and line.startswith('RNG Start Seed:'):
                end_type = "Reset"
            elif line.startswith('Game Over.'):
                end_type = "Death"
                died_to = re.search(r'(?i)Killed by \((.*)\) spawned', line).group(1)
            if end_type:
                last_run = {
                    "bosses":   self.state.bosses,
                    "items":    self.state.item_list,
                    "seed":     self.state.seed,
                    "died_to":  died_to,
                    "end_type": end_type
                }
                self.run_ended = True
                self.log.debug("End of Run! %s", last_run)
                if end_type != "Reset":
                    self.__save_file(self.run_start_line, line_number, last_run)

    def __save_file(self, start, end, last_run):
        dir_name = self.file_prefix + "run_logs"
        if not os.path.isdir(dir_name):
            os.mkdir(dir_name)
        timestamp = int(time.time())
        seed = self.state.seed.replace(" ", "")
        data = "\n".join(self.splitfile[start:end + 1])
        # FIXME improve
        data = "%s\nRUN_OVER_LINE\n%s" % (data, last_run)
        run_name = "%s%s.log" % (seed, timestamp)
        in_memory_file = StringIO.StringIO()
        with zipfile.ZipFile(in_memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
            zf.writestr(run_name, data)
        with open(self.file_prefix + "run_logs/" + run_name + ".zip", "wb") as f:
            f.write(in_memory_file.getvalue())