Skip to content

Commit

Permalink
Mega Man 2: Implement New Game (#3256)
Browse files Browse the repository at this point in the history
* 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
Silvris and Exempt-Medic authored Aug 20, 2024
1 parent c4e7b6c commit 0e6e359
Show file tree
Hide file tree
Showing 22 changed files with 3,788 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Currently, the following games are supported:
* A Hat in Time
* Old School Runescape
* Kingdom Hearts 1
* Mega Man 2

For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
Expand Down
3 changes: 3 additions & 0 deletions docs/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536

# Mega Man 2
/worlds/mm2/ @Silvris

# MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic

Expand Down
5 changes: 5 additions & 0 deletions inno_setup.iss
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";

Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";

Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
Expand Down
290 changes: 290 additions & 0 deletions worlds/mm2/__init__.py
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]
Loading

0 comments on commit 0e6e359

Please sign in to comment.