mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00

* 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]
|