-
Notifications
You must be signed in to change notification settings - Fork 703
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> * 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 <[email protected]>
- Loading branch information
1 parent
c4e7b6c
commit 0e6e359
Showing
22 changed files
with
3,788 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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] |
Oops, something went wrong.