* initial (broken) commit * small work on init * Update Items.py * beginning work, some rom patches * commit progress from bh branch * deathlink, fix soft-reset kill, e-tank loss * begin work on targeting new bhclient * write font * definitely didn't forget to add the other two hashes no * update to modern options, begin colors * fix 6th letter bug * palette shuffle + logic rewrite * fix a bunch of pointers * fix color changes, deathlink, and add wily 5 req * adjust weapon weakness generation * Update Rules.py * attempt wily 5 softlock fix * add explicit test for rbm weaknesses * fix difficulty and hard reset * fix connect deathlink and off by one item color * fix atomic fire again * de-jank deathlink * rewrite wily5 rule * fix rare solo-gen fill issue, hopefully * Update Client.py * fix wily 5 requirements * undo fill hook * fix picopico-kun rules * for real this time * update minimum damage requirement * begin move to procedure patch * finish move to APPP, allow rando boobeam, color updates * fix color bug, UT support? * what do you mean I forgot the procedure * fix UT? * plando weakness and fixes * sfx when item received, more time stopper edge cases * Update test_weakness.py * fix rules and color bug * fix color bug, support reduced flashing * major world overhaul * Update Locations.py * fix first found bugs * mypy cleanup * headerless roms * Update Rom.py * further cleanup * work on energylink * el fixes * update to energylink 2.0 packet * energylink balancing * potentially break other clients, more balancing * Update Items.py * remove startup change from basepatch we write that in patch, since we also need to clean the area before applying * el balancing and feedback * hopefully less test failures? * implement world version check * add weapon/health option * Update Rom.py * x/x2 * specials * Update Color.py * Update Options.py * finally apply location groups * bump minor version number instead * fix duplicate stage sends * validate wily 5, tests * see if renaming fixes * add shuffled weakness * remove passwords * refresh rbm select, fix wily 5 validation * forgot we can't check 0 * oops I broke the basepatch (remove failing test later) * fix solo gen fill error? * fix webhost patch recognition * fix imports, basepatch * move to flexibility metric for boss validation * special case boobeam trap * block strobe on stage select init * more energylink balancing * bump world version * wily HP inaccurate in validation * fix validation edge case * save last completed wily to data storage * mypy and pep8 cleanup * fix file browse validation * fix test failure, add enemy weakness * remove test seed * update enemy damage * inno setup * Update en_Mega Man 2.md * setup guide * Update en_Mega Man 2.md * finish plando weakness section * starting rbm edge case * remove * imports * properly wrap later weakness additions in regen playthrough * fix import * forgot readme * remove time stopper special casing since we moved to proper wily 5 validation, this special casing is no longer important * properly type added locations * Update CODEOWNERS * add animation reduction * deprioritize Time Stopper in rush checks * special case wily phase 1 * fix key error * forgot the test * music and general cleanup * the great rename * fix import * thanks pycharm * reorder palette shuffle * account for alien on shuffled weakness * apply suggestions * fix seedbleed * fix invalid buster passthrough * fix weakness landing beneath required amount * fix failsafe * finish music * fix Time Stopper on Flash/Alien * asar pls * Apply suggestions from code review Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * world helpers * init cleanup * apostrophes * clearer wording * mypy and cleanup * options doc cleanup * Update rom.py * rules cleanup * Update __init__.py * Update __init__.py * move to defaultdict * cleanup world helpers * Update __init__.py * remove unnecessary line from fill hook * forgot the other one * apply code review * remove collect * Update rules.py * forgot another --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
		
			
				
	
	
		
			291 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			291 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
import hashlib
 | 
						|
import logging
 | 
						|
from copy import deepcopy
 | 
						|
from typing import Dict, Any, TYPE_CHECKING, Optional, Sequence, Tuple, ClassVar, List
 | 
						|
 | 
						|
from BaseClasses import Tutorial, ItemClassification, MultiWorld, Item, Location
 | 
						|
from worlds.AutoWorld import World, WebWorld
 | 
						|
from .names import (dr_wily, heat_man_stage, air_man_stage, wood_man_stage, bubble_man_stage, quick_man_stage,
 | 
						|
                    flash_man_stage, metal_man_stage, crash_man_stage)
 | 
						|
from .items import (item_table, item_names, MM2Item, filler_item_weights, robot_master_weapon_table,
 | 
						|
                    stage_access_table, item_item_table, lookup_item_to_id)
 | 
						|
from .locations import (MM2Location, mm2_regions, MM2Region, energy_pickups, etank_1ups, lookup_location_to_id,
 | 
						|
                        location_groups)
 | 
						|
from .rom import patch_rom, MM2ProcedurePatch, MM2LCHASH, PROTEUSHASH, MM2VCHASH, MM2NESHASH
 | 
						|
from .options import MM2Options, Consumables
 | 
						|
from .client import MegaMan2Client
 | 
						|
from .rules import set_rules, weapon_damage, robot_masters, weapons_to_name, minimum_weakness_requirement
 | 
						|
import os
 | 
						|
import threading
 | 
						|
import base64
 | 
						|
import settings
 | 
						|
logger = logging.getLogger("Mega Man 2")
 | 
						|
 | 
						|
if TYPE_CHECKING:
 | 
						|
    from BaseClasses import CollectionState
 | 
						|
 | 
						|
 | 
						|
class MM2Settings(settings.Group):
 | 
						|
    class RomFile(settings.UserFilePath):
 | 
						|
        """File name of the MM2 EN rom"""
 | 
						|
        description = "Mega Man 2 ROM File"
 | 
						|
        copy_to: Optional[str] = "Mega Man 2 (USA).nes"
 | 
						|
        md5s = [MM2NESHASH, MM2VCHASH, MM2LCHASH, PROTEUSHASH]
 | 
						|
 | 
						|
        def browse(self: settings.T,
 | 
						|
                   filetypes: Optional[Sequence[Tuple[str, Sequence[str]]]] = None,
 | 
						|
                   **kwargs: Any) -> Optional[settings.T]:
 | 
						|
            if not filetypes:
 | 
						|
                file_types = [("NES", [".nes"]), ("Program", [".exe"])]  # LC1 is only a windows executable, no linux
 | 
						|
                return super().browse(file_types, **kwargs)
 | 
						|
            else:
 | 
						|
                return super().browse(filetypes, **kwargs)
 | 
						|
 | 
						|
        @classmethod
 | 
						|
        def validate(cls, path: str) -> None:
 | 
						|
            """Try to open and validate file against hashes"""
 | 
						|
            with open(path, "rb", buffering=0) as f:
 | 
						|
                try:
 | 
						|
                    f.seek(0)
 | 
						|
                    if f.read(4) == b"NES\x1A":
 | 
						|
                        f.seek(16)
 | 
						|
                    else:
 | 
						|
                        f.seek(0)
 | 
						|
                    cls._validate_stream_hashes(f)
 | 
						|
                    base_rom_bytes = f.read()
 | 
						|
                    basemd5 = hashlib.md5()
 | 
						|
                    basemd5.update(base_rom_bytes)
 | 
						|
                    if basemd5.hexdigest() == PROTEUSHASH:
 | 
						|
                        # we need special behavior here
 | 
						|
                        cls.copy_to = None
 | 
						|
                except ValueError:
 | 
						|
                    raise ValueError(f"File hash does not match for {path}")
 | 
						|
 | 
						|
    rom_file: RomFile = RomFile(RomFile.copy_to)
 | 
						|
 | 
						|
 | 
						|
class MM2WebWorld(WebWorld):
 | 
						|
    theme = "partyTime"
 | 
						|
    tutorials = [
 | 
						|
 | 
						|
        Tutorial(
 | 
						|
           "Multiworld Setup Guide",
 | 
						|
           "A guide to setting up the Mega Man 2 randomizer connected to an Archipelago Multiworld.",
 | 
						|
           "English",
 | 
						|
           "setup_en.md",
 | 
						|
           "setup/en",
 | 
						|
           ["Silvris"]
 | 
						|
        )
 | 
						|
    ]
 | 
						|
 | 
						|
 | 
						|
class MM2World(World):
 | 
						|
    """
 | 
						|
    In the year 200X, following his prior defeat by Mega Man, the evil Dr. Wily has returned to take over the world with
 | 
						|
    his own group of Robot Masters. Mega Man once again sets out to defeat the eight Robot Masters and stop Dr. Wily.
 | 
						|
 | 
						|
    """
 | 
						|
 | 
						|
    game = "Mega Man 2"
 | 
						|
    settings: ClassVar[MM2Settings]
 | 
						|
    options_dataclass = MM2Options
 | 
						|
    options: MM2Options
 | 
						|
    item_name_to_id = lookup_item_to_id
 | 
						|
    location_name_to_id = lookup_location_to_id
 | 
						|
    item_name_groups = item_names
 | 
						|
    location_name_groups = location_groups
 | 
						|
    web = MM2WebWorld()
 | 
						|
    rom_name: bytearray
 | 
						|
    world_version: Tuple[int, int, int] = (0, 3, 1)
 | 
						|
    wily_5_weapons: Dict[int, List[int]]
 | 
						|
 | 
						|
    def __init__(self, world: MultiWorld, player: int):
 | 
						|
        self.rom_name = bytearray()
 | 
						|
        self.rom_name_available_event = threading.Event()
 | 
						|
        super().__init__(world, player)
 | 
						|
        self.weapon_damage = deepcopy(weapon_damage)
 | 
						|
        self.wily_5_weapons = {}
 | 
						|
 | 
						|
    def create_regions(self) -> None:
 | 
						|
        menu = MM2Region("Menu", self.player, self.multiworld)
 | 
						|
        self.multiworld.regions.append(menu)
 | 
						|
        for region in mm2_regions:
 | 
						|
            stage = MM2Region(region, self.player, self.multiworld)
 | 
						|
            required_items = mm2_regions[region][0]
 | 
						|
            locations = mm2_regions[region][1]
 | 
						|
            prev_stage = mm2_regions[region][2]
 | 
						|
            if prev_stage is None:
 | 
						|
                menu.connect(stage, f"To {region}",
 | 
						|
                             lambda state, items=required_items: state.has_all(items, self.player))
 | 
						|
            else:
 | 
						|
                old_stage = self.get_region(prev_stage)
 | 
						|
                old_stage.connect(stage, f"To {region}",
 | 
						|
                                  lambda state, items=required_items: state.has_all(items, self.player))
 | 
						|
            stage.add_locations(locations, MM2Location)
 | 
						|
            for location in stage.get_locations():
 | 
						|
                if location.address is None and location.name != dr_wily:
 | 
						|
                    location.place_locked_item(MM2Item(location.name, ItemClassification.progression,
 | 
						|
                                                       None, self.player))
 | 
						|
            if region in etank_1ups and self.options.consumables in (Consumables.option_1up_etank,
 | 
						|
                                                                     Consumables.option_all):
 | 
						|
                stage.add_locations(etank_1ups[region], MM2Location)
 | 
						|
            if region in energy_pickups and self.options.consumables in (Consumables.option_weapon_health,
 | 
						|
                                                                         Consumables.option_all):
 | 
						|
                stage.add_locations(energy_pickups[region], MM2Location)
 | 
						|
            self.multiworld.regions.append(stage)
 | 
						|
 | 
						|
    def create_item(self, name: str) -> MM2Item:
 | 
						|
        item = item_table[name]
 | 
						|
        classification = ItemClassification.filler
 | 
						|
        if item.progression:
 | 
						|
            classification = ItemClassification.progression_skip_balancing \
 | 
						|
                if item.skip_balancing else ItemClassification.progression
 | 
						|
        if item.useful:
 | 
						|
            classification |= ItemClassification.useful
 | 
						|
        return MM2Item(name, classification, item.code, self.player)
 | 
						|
 | 
						|
    def get_filler_item_name(self) -> str:
 | 
						|
        return self.random.choices(list(filler_item_weights.keys()),
 | 
						|
                                              weights=list(filler_item_weights.values()))[0]
 | 
						|
 | 
						|
    def create_items(self) -> None:
 | 
						|
        itempool = []
 | 
						|
        # grab first robot master
 | 
						|
        robot_master = self.item_id_to_name[0x880101 + self.options.starting_robot_master.value]
 | 
						|
        self.multiworld.push_precollected(self.create_item(robot_master))
 | 
						|
        itempool.extend([self.create_item(name) for name in stage_access_table.keys()
 | 
						|
                         if name != robot_master])
 | 
						|
        itempool.extend([self.create_item(name) for name in robot_master_weapon_table.keys()])
 | 
						|
        itempool.extend([self.create_item(name) for name in item_item_table.keys()])
 | 
						|
        total_checks = 24
 | 
						|
        if self.options.consumables in (Consumables.option_1up_etank,
 | 
						|
                                        Consumables.option_all):
 | 
						|
            total_checks += 20
 | 
						|
        if self.options.consumables in (Consumables.option_weapon_health,
 | 
						|
                                        Consumables.option_all):
 | 
						|
            total_checks += 27
 | 
						|
        remaining = total_checks - len(itempool)
 | 
						|
        itempool.extend([self.create_item(name)
 | 
						|
                         for name in self.random.choices(list(filler_item_weights.keys()),
 | 
						|
                                                                    weights=list(filler_item_weights.values()),
 | 
						|
                                                                    k=remaining)])
 | 
						|
        self.multiworld.itempool += itempool
 | 
						|
 | 
						|
    set_rules = set_rules
 | 
						|
 | 
						|
    def generate_early(self) -> None:
 | 
						|
        if (not self.options.yoku_jumps
 | 
						|
            and self.options.starting_robot_master == "heat_man") or \
 | 
						|
                (not self.options.enable_lasers
 | 
						|
                 and self.options.starting_robot_master == "quick_man"):
 | 
						|
            robot_master_pool = [1, 2, 3, 5, 6, 7, ]
 | 
						|
            if self.options.yoku_jumps:
 | 
						|
                robot_master_pool.append(0)
 | 
						|
            if self.options.enable_lasers:
 | 
						|
                robot_master_pool.append(4)
 | 
						|
            self.options.starting_robot_master.value = self.random.choice(robot_master_pool)
 | 
						|
            logger.warning(
 | 
						|
                f"Mega Man 2 ({self.player_name}): "
 | 
						|
                f"Incompatible starting Robot Master, changing to "
 | 
						|
                f"{self.options.starting_robot_master.current_key.replace('_', ' ').title()}")
 | 
						|
 | 
						|
    def generate_basic(self) -> None:
 | 
						|
        goal_location = self.get_location(dr_wily)
 | 
						|
        goal_location.place_locked_item(MM2Item("Victory", ItemClassification.progression, None, self.player))
 | 
						|
        self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
 | 
						|
 | 
						|
    def fill_hook(self,
 | 
						|
                  progitempool: List["Item"],
 | 
						|
                  usefulitempool: List["Item"],
 | 
						|
                  filleritempool: List["Item"],
 | 
						|
                  fill_locations: List["Location"]) -> None:
 | 
						|
        # on a solo gen, fill can try to force Wily into sphere 2, but for most generations this is impossible
 | 
						|
        # since MM2 can have a 2 item sphere 1, and 3 items are required for Wily
 | 
						|
        if self.multiworld.players > 1:
 | 
						|
            return  # Don't need to change anything on a multi gen, fill should be able to solve it with a 4 sphere 1
 | 
						|
        rbm_to_item = {
 | 
						|
            0: heat_man_stage,
 | 
						|
            1: air_man_stage,
 | 
						|
            2: wood_man_stage,
 | 
						|
            3: bubble_man_stage,
 | 
						|
            4: quick_man_stage,
 | 
						|
            5: flash_man_stage,
 | 
						|
            6: metal_man_stage,
 | 
						|
            7: crash_man_stage
 | 
						|
        }
 | 
						|
        affected_rbm = [2, 3]  # Wood and Bubble will always have this happen
 | 
						|
        possible_rbm = [1, 5]  # Air and Flash are always valid targets, due to Item 2/3 receive
 | 
						|
        if self.options.consumables:
 | 
						|
            possible_rbm.append(6)  # Metal has 3 consumables
 | 
						|
            possible_rbm.append(7)  # Crash has 3 consumables
 | 
						|
            if self.options.enable_lasers:
 | 
						|
                possible_rbm.append(4)  # Quick has a lot of consumables, but needs logical time stopper if not enabled
 | 
						|
        else:
 | 
						|
            affected_rbm.extend([6, 7])  # only two checks on non consumables
 | 
						|
        if self.options.yoku_jumps:
 | 
						|
            possible_rbm.append(0)  # Heat has 3 locations always, but might need 2 items logically
 | 
						|
        if self.options.starting_robot_master.value in affected_rbm:
 | 
						|
            rbm_names = list(map(lambda s: rbm_to_item[s], possible_rbm))
 | 
						|
            valid_second = [item for item in progitempool
 | 
						|
                            if item.name in rbm_names
 | 
						|
                            and item.player == self.player]
 | 
						|
            placed_item = self.random.choice(valid_second)
 | 
						|
            rbm_defeated = (f"{robot_masters[self.options.starting_robot_master.value].replace(' Defeated', '')}"
 | 
						|
                            f" - Defeated")
 | 
						|
            rbm_location = self.get_location(rbm_defeated)
 | 
						|
            rbm_location.place_locked_item(placed_item)
 | 
						|
            progitempool.remove(placed_item)
 | 
						|
            fill_locations.remove(rbm_location)
 | 
						|
            target_rbm = (placed_item.code & 0xF) - 1
 | 
						|
            if self.options.strict_weakness or (self.options.random_weakness
 | 
						|
                                                and not (self.weapon_damage[0][target_rbm] > 0)):
 | 
						|
                # we need to find a weakness for this boss
 | 
						|
                weaknesses = [weapon for weapon in range(1, 9)
 | 
						|
                              if self.weapon_damage[weapon][target_rbm] >= minimum_weakness_requirement[weapon]]
 | 
						|
                weapons = list(map(lambda s: weapons_to_name[s], weaknesses))
 | 
						|
                valid_weapons = [item for item in progitempool
 | 
						|
                                 if item.name in weapons
 | 
						|
                                 and item.player == self.player]
 | 
						|
                placed_weapon = self.random.choice(valid_weapons)
 | 
						|
                weapon_name = next(name for name, idx in lookup_location_to_id.items()
 | 
						|
                                   if idx == 0x880101 + self.options.starting_robot_master.value)
 | 
						|
                weapon_location = self.get_location(weapon_name)
 | 
						|
                weapon_location.place_locked_item(placed_weapon)
 | 
						|
                progitempool.remove(placed_weapon)
 | 
						|
                fill_locations.remove(weapon_location)
 | 
						|
 | 
						|
    def generate_output(self, output_directory: str) -> None:
 | 
						|
        try:
 | 
						|
            patch = MM2ProcedurePatch(player=self.player, player_name=self.player_name)
 | 
						|
            patch_rom(self, patch)
 | 
						|
 | 
						|
            self.rom_name = patch.name
 | 
						|
 | 
						|
            patch.write(os.path.join(output_directory,
 | 
						|
                                     f"{self.multiworld.get_out_file_name_base(self.player)}{patch.patch_file_ending}"))
 | 
						|
        except Exception:
 | 
						|
            raise
 | 
						|
        finally:
 | 
						|
            self.rom_name_available_event.set()  # make sure threading continues and errors are collected
 | 
						|
 | 
						|
    def fill_slot_data(self) -> Dict[str, Any]:
 | 
						|
        return {
 | 
						|
            "death_link": self.options.death_link.value,
 | 
						|
            "weapon_damage": self.weapon_damage,
 | 
						|
            "wily_5_weapons": self.wily_5_weapons,
 | 
						|
        }
 | 
						|
 | 
						|
    def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Dict[str, Any]:
 | 
						|
        local_weapon = {int(key): value for key, value in slot_data["weapon_damage"].items()}
 | 
						|
        local_wily = {int(key): value for key, value in slot_data["wily_5_weapons"].items()}
 | 
						|
        return {"weapon_damage": local_weapon, "wily_5_weapons": local_wily}
 | 
						|
 | 
						|
    def modify_multidata(self, multidata: Dict[str, Any]) -> None:
 | 
						|
        # wait for self.rom_name to be available.
 | 
						|
        self.rom_name_available_event.wait()
 | 
						|
        rom_name = getattr(self, "rom_name", None)
 | 
						|
        # we skip in case of error, so that the original error in the output thread is the one that gets raised
 | 
						|
        if rom_name:
 | 
						|
            new_name = base64.b64encode(bytes(self.rom_name)).decode()
 | 
						|
            multidata["connect_names"][new_name] = multidata["connect_names"][self.player_name]
 |