コード例 #1
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)
        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('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 is None:
            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
        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)
                if self.state.player != 19:
                    self.state.remove_item_from_soul()
        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_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 (space_split[6] == '[New,' and self.opt.game_version == "Repentance") or self.opt.game_version == "Antibirth":
            self.__trigger_new_run(line_number)
        elif (space_split[6] == '[Continue,' and self.opt.game_version == "Repentance"):
            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 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":
            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.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)
        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" and self.state.player == 19
        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+  
        if self.state.player == 19 and not is_Esau_item and not is_Jacob_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 != 19 and is_Jacob_item:
            self.state.player = 19
        end_name = -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)" or "Adding collectible 105 (The D6) to Player 0 (Isaac)" in Repentance
        if self.opt.game_version == "Repentance":
            item_name = " ".join(space_split[3:-4])[1:end_name]
        else:
            item_name = " ".join(space_split[3:])[1:end_name]  
        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, 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")

        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"""
        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
            self.state.add_item(Item("3000", 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
        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
            self.state.add_item(Item("3001", 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
            self.state.add_item(Item("3002", 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 != 37 and self.state.player != 39
        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)
        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
        # 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" and int(space_split[3]) > 30000:
                item_id = space_split[3]
            elif 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)

        if item_id in ("144", "238", "239", "278", "388", "626", "627"):
            self.__parse_remove_multi_items(item_id=item_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 __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