Mega Man 2: Implement New Game (#3256)
* 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>
This commit is contained in:
		
							
								
								
									
										290
									
								
								worlds/mm2/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								worlds/mm2/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,290 @@ | ||||
| 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] | ||||
		Reference in New Issue
	
	Block a user
	 Silvris
					Silvris