mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-12 02:23:47 -07:00
Compare commits
108 Commits
webhost_bi
...
active/rc-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8dc0dc592 | ||
|
|
e8f014fcc8 | ||
|
|
89085ea7b8 | ||
|
|
03b638d027 | ||
|
|
3c802d03a1 | ||
|
|
a8e926a1a9 | ||
|
|
56c2272bfd | ||
|
|
47e581bc30 | ||
|
|
3235863f2e | ||
|
|
f00d29e072 | ||
|
|
d000c0f265 | ||
|
|
94136ac223 | ||
|
|
72ff9b1a7d | ||
|
|
4b37283d22 | ||
|
|
c3659fb3ef | ||
|
|
1a8a71f593 | ||
|
|
c255ea8fc6 | ||
|
|
fd81553420 | ||
|
|
2c279cef09 | ||
|
|
07a1ec0a1d | ||
|
|
0b6ba103c5 | ||
|
|
d57b3078b5 | ||
|
|
baad3ceede | ||
|
|
bd3686597f | ||
|
|
805b978403 | ||
|
|
123e1f5d95 | ||
|
|
aff006a85f | ||
|
|
1748048b44 | ||
|
|
44e424362e | ||
|
|
8421ccce12 | ||
|
|
f76ea191c1 | ||
|
|
f81e2fdf73 | ||
|
|
07e2381cbb | ||
|
|
371db53371 | ||
|
|
5b99118dda | ||
|
|
4bb6cac7c4 | ||
|
|
99601ccebc | ||
|
|
53956b7d4d | ||
|
|
b38548f89b | ||
|
|
a8ac828241 | ||
|
|
fc2cb3c961 | ||
|
|
9efcba5323 | ||
|
|
9f29859810 | ||
|
|
366fd3712a | ||
|
|
7f2be5f0f5 | ||
|
|
2725720406 | ||
|
|
10d2908339 | ||
|
|
9653c8d29c | ||
|
|
62afec9733 | ||
|
|
b53f9d3773 | ||
|
|
62f56e165a | ||
|
|
eebd83df76 | ||
|
|
33f03387c4 | ||
|
|
779dd46658 | ||
|
|
4ea7fbbcba | ||
|
|
9ab7c56791 | ||
|
|
e2823aa044 | ||
|
|
368eafae86 | ||
|
|
6779b4fcf3 | ||
|
|
6a94a9e6ca | ||
|
|
c290386950 | ||
|
|
60773ddf83 | ||
|
|
3ecd856e29 | ||
|
|
980a229aaa | ||
|
|
2bec17b397 | ||
|
|
821645a881 | ||
|
|
f03d1cad3e | ||
|
|
551dbf44f6 | ||
|
|
61f893437a | ||
|
|
08a6ee2b3a | ||
|
|
b0615590fc | ||
|
|
0a0faefab2 | ||
|
|
d6473fa0ed | ||
|
|
f8b730308d | ||
|
|
8800124c4e | ||
|
|
88dc83e557 | ||
|
|
ed77f58f13 | ||
|
|
9b098d6f6a | ||
|
|
055acf4826 | ||
|
|
2ec4be6f1f | ||
|
|
9996c12ef9 | ||
|
|
1346a89a4a | ||
|
|
a294e1cdc9 | ||
|
|
aad980a3a2 | ||
|
|
6f7fce9c73 | ||
|
|
63bc205dab | ||
|
|
4a355f3585 | ||
|
|
1cdd657068 | ||
|
|
f4ec119900 | ||
|
|
9c00b546dd | ||
|
|
59051cda24 | ||
|
|
9489a950cb | ||
|
|
60f6f0f8a8 | ||
|
|
7c1726bcc7 | ||
|
|
16b47b0a7f | ||
|
|
41b0c7edc6 | ||
|
|
4d5853d8e3 | ||
|
|
8f4e4cf6b2 | ||
|
|
e5f168e2dd | ||
|
|
e1df5b75ff | ||
|
|
6dd2367696 | ||
|
|
9aae61ce0e | ||
|
|
65661e384b | ||
|
|
7ba531fb27 | ||
|
|
1815645994 | ||
|
|
b326045cb7 | ||
|
|
392a45ec89 | ||
|
|
354c9aea4c |
@@ -727,6 +727,7 @@ class CollectionState():
|
||||
advancements: Set[Location]
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
"""Internal cache for Advancement Locations already checked by this CollectionState. Not for use in logic."""
|
||||
stale: Dict[int, bool]
|
||||
allow_partial_entrances: bool
|
||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||
|
||||
1
Fill.py
1
Fill.py
@@ -280,6 +280,7 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
item_to_place = itempool.pop()
|
||||
spot_to_fill: typing.Optional[Location] = None
|
||||
|
||||
# going through locations in the same order as the provided `locations` argument
|
||||
for i, location in enumerate(locations):
|
||||
if location_can_fill_item(location, item_to_place):
|
||||
# popping by index is faster than removing by content,
|
||||
|
||||
204
MultiServer.py
204
MultiServer.py
@@ -21,7 +21,7 @@ import time
|
||||
import typing
|
||||
import weakref
|
||||
import zlib
|
||||
from signal import SIGINT, SIGTERM
|
||||
from signal import SIGINT, SIGTERM, signal
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -44,8 +44,9 @@ import NetUtils
|
||||
import Utils
|
||||
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType, LocationStore, MultiData, Hint, HintStatus
|
||||
SlotType, LocationStore, MultiData, Hint, HintStatus, GamesPackage
|
||||
from BaseClasses import ItemClassification
|
||||
from apmw.multiserver.gamespackagecache import GamesPackageCache
|
||||
|
||||
|
||||
min_client_version = Version(0, 5, 0)
|
||||
@@ -241,21 +242,38 @@ class Context:
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
generator_version = Version(0, 0, 0)
|
||||
checksums: typing.Dict[str, str]
|
||||
played_games: set[str]
|
||||
item_names: typing.Dict[str, typing.Dict[int, str]]
|
||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
item_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
|
||||
location_names: typing.Dict[str, typing.Dict[int, str]]
|
||||
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
location_name_groups: typing.Dict[str, typing.Dict[str, list[str]]]
|
||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
||||
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
|
||||
""" each sphere is { player: { location_id, ... } } """
|
||||
games_package_cache: GamesPackageCache
|
||||
logger: logging.Logger
|
||||
|
||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
|
||||
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
server_password: str,
|
||||
password: str,
|
||||
location_check_points: int,
|
||||
hint_cost: int,
|
||||
item_cheat: bool,
|
||||
release_mode: str = "disabled",
|
||||
collect_mode="disabled",
|
||||
countdown_mode: str = "auto",
|
||||
remaining_mode: str = "disabled",
|
||||
auto_shutdown: typing.SupportsFloat = 0,
|
||||
compatibility: int = 2,
|
||||
log_network: bool = False,
|
||||
games_package_cache: GamesPackageCache | None = None,
|
||||
logger: logging.Logger = logging.getLogger(),
|
||||
) -> None:
|
||||
self.logger = logger
|
||||
super(Context, self).__init__()
|
||||
self.slot_info = {}
|
||||
@@ -306,6 +324,7 @@ class Context:
|
||||
self.save_dirty = False
|
||||
self.tags = ['AP']
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.played_games = set()
|
||||
self.minimum_client_versions: typing.Dict[int, Version] = {}
|
||||
self.seed_name = ""
|
||||
self.groups = {}
|
||||
@@ -315,9 +334,10 @@ class Context:
|
||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||
self.read_data = {}
|
||||
self.spheres = []
|
||||
self.games_package_cache = games_package_cache or GamesPackageCache()
|
||||
|
||||
# init empty to satisfy linter, I suppose
|
||||
self.gamespackage = {}
|
||||
self.reduced_games_package = {}
|
||||
self.checksums = {}
|
||||
self.item_name_groups = {}
|
||||
self.location_name_groups = {}
|
||||
@@ -329,50 +349,11 @@ class Context:
|
||||
lambda: Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})'))
|
||||
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||
|
||||
self._load_game_data()
|
||||
|
||||
# Data package retrieval
|
||||
def _load_game_data(self):
|
||||
import worlds
|
||||
self.gamespackage = worlds.network_data_package["games"]
|
||||
|
||||
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()}
|
||||
self.location_name_groups = {world_name: world.location_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()}
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||
|
||||
for game_package in self.gamespackage.values():
|
||||
# remove groups from data sent to clients
|
||||
del game_package["item_name_groups"]
|
||||
del game_package["location_name_groups"]
|
||||
|
||||
def _init_game_data(self):
|
||||
for game_name, game_package in self.gamespackage.items():
|
||||
if "checksum" in game_package:
|
||||
self.checksums[game_name] = game_package["checksum"]
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[game_name][item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[game_name][location_id] = location_name
|
||||
self.all_item_and_group_names[game_name] = \
|
||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||
self.all_location_and_group_names[game_name] = \
|
||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
||||
|
||||
archipelago_item_names = self.item_names["Archipelago"]
|
||||
archipelago_location_names = self.location_names["Archipelago"]
|
||||
for game in [game_name for game_name in self.gamespackage if game_name != "Archipelago"]:
|
||||
# Add Archipelago items and locations to each data package.
|
||||
self.item_names[game].update(archipelago_item_names)
|
||||
self.location_names[game].update(archipelago_location_names)
|
||||
|
||||
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
|
||||
return self.reduced_games_package[game]["item_name_to_id"] if game in self.reduced_games_package else None
|
||||
|
||||
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
|
||||
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
|
||||
return self.reduced_games_package[game]["location_name_to_id"] if game in self.reduced_games_package else None
|
||||
|
||||
# General networking
|
||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||
@@ -482,19 +463,17 @@ class Context:
|
||||
with open(multidatapath, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
self._load(self.decompress(data), {}, use_embedded_server_options)
|
||||
self._load(self.decompress(data), use_embedded_server_options)
|
||||
self.data_filename = multidatapath
|
||||
|
||||
@staticmethod
|
||||
def decompress(data: bytes) -> dict:
|
||||
def decompress(data: bytes) -> typing.Any:
|
||||
format_version = data[0]
|
||||
if format_version > 3:
|
||||
raise Utils.VersionException("Incompatible multidata.")
|
||||
return restricted_loads(zlib.decompress(data[1:]))
|
||||
|
||||
def _load(self, decoded_obj: MultiData, game_data_packages: typing.Dict[str, typing.Any],
|
||||
use_embedded_server_options: bool):
|
||||
|
||||
def _load(self, decoded_obj: MultiData, use_embedded_server_options: bool) -> None:
|
||||
self.read_data = {}
|
||||
# there might be a better place to put this.
|
||||
race_mode = decoded_obj.get("race_mode", 0)
|
||||
@@ -515,6 +494,7 @@ class Context:
|
||||
|
||||
self.slot_info = decoded_obj["slot_info"]
|
||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||
self.played_games = {"Archipelago"} | {self.games[x] for x in range(1, len(self.games) + 1)}
|
||||
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
||||
if slot_info.type == SlotType.group}
|
||||
|
||||
@@ -559,18 +539,11 @@ class Context:
|
||||
server_options = decoded_obj.get("server_options", {})
|
||||
self._set_options(server_options)
|
||||
|
||||
# embedded data package
|
||||
for game_name, data in decoded_obj.get("datapackage", {}).items():
|
||||
if game_name in game_data_packages:
|
||||
data = game_data_packages[game_name]
|
||||
self.logger.info(f"Loading embedded data package for game {game_name}")
|
||||
self.gamespackage[game_name] = data
|
||||
self.item_name_groups[game_name] = data["item_name_groups"]
|
||||
if "location_name_groups" in data:
|
||||
self.location_name_groups[game_name] = data["location_name_groups"]
|
||||
del data["location_name_groups"]
|
||||
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
|
||||
# load and apply world data and (embedded) data package
|
||||
self._load_world_data()
|
||||
self._load_data_package(decoded_obj.get("datapackage", {}))
|
||||
self._init_game_data()
|
||||
|
||||
for game_name, data in self.item_name_groups.items():
|
||||
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
|
||||
for game_name, data in self.location_name_groups.items():
|
||||
@@ -579,6 +552,55 @@ class Context:
|
||||
# sorted access spheres
|
||||
self.spheres = decoded_obj.get("spheres", [])
|
||||
|
||||
def _load_world_data(self) -> None:
|
||||
import worlds
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
# TODO: move hint_blacklist into GamesPackage?
|
||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||
|
||||
def _load_data_package(self, data_package: dict[str, GamesPackage]) -> None:
|
||||
"""Populates reduced_games_package, item_name_groups, location_name_groups from static data and data_package"""
|
||||
# NOTE: for worlds loaded from db, only checksum is set in GamesPackage, but this is handled by cache
|
||||
for game_name in sorted(self.played_games):
|
||||
if game_name in data_package:
|
||||
self.logger.info(f"Loading embedded data package for game {game_name}")
|
||||
data = self.games_package_cache.get(game_name, data_package[game_name])
|
||||
else:
|
||||
# NOTE: we still allow uploading a game without datapackage. Once that is changed, we could drop this.
|
||||
data = self.games_package_cache.get_static(game_name)
|
||||
(
|
||||
self.reduced_games_package[game_name],
|
||||
self.item_name_groups[game_name],
|
||||
self.location_name_groups[game_name],
|
||||
) = data
|
||||
|
||||
del self.games_package_cache # Not used past this point. Free memory.
|
||||
|
||||
def _init_game_data(self) -> None:
|
||||
"""Update internal values from previously loaded data packages"""
|
||||
for game_name, game_package in self.reduced_games_package.items():
|
||||
if game_name not in self.played_games:
|
||||
continue
|
||||
if "checksum" in game_package:
|
||||
self.checksums[game_name] = game_package["checksum"]
|
||||
# NOTE: we could save more memory by moving the stuff below to data package cache as well
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[game_name][item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[game_name][location_id] = location_name
|
||||
self.all_item_and_group_names[game_name] = \
|
||||
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
|
||||
self.all_location_and_group_names[game_name] = \
|
||||
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
|
||||
|
||||
archipelago_item_names = self.item_names["Archipelago"]
|
||||
archipelago_location_names = self.location_names["Archipelago"]
|
||||
for game in [game_name for game_name in self.reduced_games_package if game_name != "Archipelago"]:
|
||||
# Add Archipelago items and locations to each data package.
|
||||
self.item_names[game].update(archipelago_item_names)
|
||||
self.location_names[game].update(archipelago_location_names)
|
||||
|
||||
# saving
|
||||
|
||||
def save(self, now=False) -> bool:
|
||||
@@ -919,12 +941,10 @@ async def server(websocket: "ServerConnection", path: str = "/", ctx: Context =
|
||||
|
||||
|
||||
async def on_client_connected(ctx: Context, client: Client):
|
||||
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
|
||||
games.add("Archipelago")
|
||||
await ctx.send_msgs(client, [{
|
||||
'cmd': 'RoomInfo',
|
||||
'password': bool(ctx.password),
|
||||
'games': games,
|
||||
'games': sorted(ctx.played_games),
|
||||
# tags are for additional features in the communication.
|
||||
# Name them by feature or fork, as you feel is appropriate.
|
||||
'tags': ctx.tags,
|
||||
@@ -933,8 +953,7 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
'permissions': get_permissions(ctx),
|
||||
'hint_cost': ctx.hint_cost,
|
||||
'location_check_points': ctx.location_check_points,
|
||||
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
|
||||
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
|
||||
'datapackage_checksums': ctx.checksums,
|
||||
'seed_name': ctx.seed_name,
|
||||
'time': time.time(),
|
||||
}])
|
||||
@@ -1940,25 +1959,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
await ctx.send_msgs(client, reply)
|
||||
|
||||
elif cmd == "GetDataPackage":
|
||||
exclusions = args.get("exclusions", [])
|
||||
if "games" in args:
|
||||
games = {name: game_data for name, game_data in ctx.gamespackage.items()
|
||||
if name in set(args.get("games", []))}
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": {"games": games}}])
|
||||
# TODO: remove exclusions behaviour around 0.5.0
|
||||
elif exclusions:
|
||||
exclusions = set(exclusions)
|
||||
games = {name: game_data for name, game_data in ctx.gamespackage.items()
|
||||
if name not in exclusions}
|
||||
|
||||
package = {"games": games}
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": package}])
|
||||
|
||||
else:
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": {"games": ctx.gamespackage}}])
|
||||
games = {
|
||||
name: game_data for name, game_data in ctx.reduced_games_package.items()
|
||||
if name in set(args.get("games", []))
|
||||
}
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage", "data": {"games": games}}])
|
||||
|
||||
elif client.auth:
|
||||
if cmd == "ConnectUpdate":
|
||||
@@ -2742,12 +2747,23 @@ async def main(args: argparse.Namespace):
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
|
||||
|
||||
def stop():
|
||||
for remove_signal in [SIGINT, SIGTERM]:
|
||||
asyncio.get_event_loop().remove_signal_handler(remove_signal)
|
||||
try:
|
||||
for remove_signal in [SIGINT, SIGTERM]:
|
||||
asyncio.get_event_loop().remove_signal_handler(remove_signal)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
ctx.commandprocessor._cmd_exit()
|
||||
|
||||
for signal in [SIGINT, SIGTERM]:
|
||||
asyncio.get_event_loop().add_signal_handler(signal, stop)
|
||||
def shutdown(signum, frame):
|
||||
stop()
|
||||
|
||||
try:
|
||||
for sig in [SIGINT, SIGTERM]:
|
||||
asyncio.get_event_loop().add_signal_handler(sig, stop)
|
||||
except NotImplementedError:
|
||||
# add_signal_handler is only implemented for UNIX platforms
|
||||
for sig in [SIGINT, SIGTERM]:
|
||||
signal(sig, shutdown)
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
console_task.cancel()
|
||||
|
||||
@@ -85,6 +85,7 @@ Currently, the following games are supported:
|
||||
* APQuest
|
||||
* Satisfactory
|
||||
* EarthBound
|
||||
* Mega Man 3
|
||||
|
||||
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
|
||||
|
||||
11
Utils.py
11
Utils.py
@@ -18,6 +18,8 @@ import logging
|
||||
import warnings
|
||||
|
||||
from argparse import Namespace
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from settings import Settings, get_settings
|
||||
from time import sleep
|
||||
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
|
||||
@@ -1291,6 +1293,15 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
||||
return isinstance(obj, typing.Iterable)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""
|
||||
Implementation of Python's datetime.utcnow() function for use after deprecation.
|
||||
Needed for timezone-naive UTC datetimes stored in databases with PonyORM (upstream).
|
||||
https://ponyorm.org/ponyorm-list/2014-August/000113.html
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||
"""
|
||||
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
||||
|
||||
@@ -11,6 +11,7 @@ from pony.flask import Pony
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from Utils import title_sorted, get_file_safe_name
|
||||
from .cli import CLI
|
||||
|
||||
UPLOAD_FOLDER = os.path.relpath('uploads')
|
||||
LOGS_FOLDER = os.path.relpath('logs')
|
||||
@@ -41,6 +42,7 @@ app.config["SELFLAUNCH"] = True # application process is in charge of launching
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["GAME_PORTS"] = ["49152-65535", 0]
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
|
||||
app.config["JOB_THRESHOLD"] = 1
|
||||
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
|
||||
@@ -64,6 +66,7 @@ app.config["ASSET_RIGHTS"] = False
|
||||
|
||||
cache = Cache()
|
||||
Compress(app)
|
||||
CLI(app)
|
||||
|
||||
|
||||
def to_python(value: str) -> uuid.UUID:
|
||||
|
||||
@@ -4,14 +4,14 @@ import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import typing
|
||||
from datetime import timedelta, datetime
|
||||
from datetime import timedelta
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pony.orm import db_session, select, commit, PrimaryKey
|
||||
|
||||
from Utils import restricted_loads
|
||||
from Utils import restricted_loads, utcnow
|
||||
from .locker import Locker, AlreadyRunningException
|
||||
|
||||
_stop_event = Event()
|
||||
@@ -129,10 +129,10 @@ def autohost(config: dict):
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
room.last_activity >= utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
|
||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
||||
if room.last_activity >= utcnow() - timedelta(seconds=room.timeout + 5):
|
||||
hosters[room.id.int % len(hosters)].start_room(room.id)
|
||||
|
||||
except AlreadyRunningException:
|
||||
@@ -187,6 +187,7 @@ class MultiworldInstance():
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
self.host = config["HOST_ADDRESS"]
|
||||
self.game_ports = config["GAME_PORTS"]
|
||||
self.rooms_to_start = multiprocessing.Queue()
|
||||
self.rooms_shutting_down = multiprocessing.Queue()
|
||||
self.name = f"MultiHoster{id}"
|
||||
@@ -197,7 +198,7 @@ class MultiworldInstance():
|
||||
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.name, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host,
|
||||
self.cert, self.key, self.host, self.game_ports,
|
||||
self.rooms_to_start, self.rooms_shutting_down),
|
||||
name=self.name)
|
||||
process.start()
|
||||
|
||||
8
WebHostLib/cli/__init__.py
Normal file
8
WebHostLib/cli/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from flask import Flask
|
||||
|
||||
|
||||
class CLI:
|
||||
def __init__(self, app: Flask) -> None:
|
||||
from .stats import stats_cli
|
||||
|
||||
app.cli.add_command(stats_cli)
|
||||
36
WebHostLib/cli/stats.py
Normal file
36
WebHostLib/cli/stats.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import click
|
||||
from flask.cli import AppGroup
|
||||
from pony.orm import raw_sql
|
||||
|
||||
from Utils import format_SI_prefix
|
||||
|
||||
stats_cli = AppGroup("stats")
|
||||
|
||||
|
||||
@stats_cli.command("show")
|
||||
def show() -> None:
|
||||
from pony.orm import db_session, select
|
||||
|
||||
from WebHostLib.models import GameDataPackage
|
||||
|
||||
total_games_package_count: int = 0
|
||||
total_games_package_size: int
|
||||
top_10_package_sizes: list[tuple[int, str]] = []
|
||||
|
||||
with db_session:
|
||||
data_length = raw_sql("LENGTH(data)")
|
||||
data_length_desc = raw_sql("LENGTH(data) DESC")
|
||||
data_length_sum = raw_sql("SUM(LENGTH(data))")
|
||||
total_games_package_count = GameDataPackage.select().count()
|
||||
total_games_package_size = select(data_length_sum for _ in GameDataPackage).first() # type: ignore
|
||||
top_10_package_sizes = list(
|
||||
select((data_length, dp.checksum) for dp in GameDataPackage) # type: ignore
|
||||
.order_by(lambda _, _2: data_length_desc)
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
click.echo(f"Total number of games packages: {total_games_package_count}")
|
||||
click.echo(f"Total size of games packages: {format_SI_prefix(total_games_package_size, power=1024)}B")
|
||||
click.echo(f"Top {len(top_10_package_sizes)} biggest games packages:")
|
||||
for size, checksum in top_10_package_sizes:
|
||||
click.echo(f" {checksum}: {size:>8d}")
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import collections
|
||||
import datetime
|
||||
import functools
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import pickle
|
||||
@@ -13,7 +14,9 @@ import threading
|
||||
import time
|
||||
import typing
|
||||
import sys
|
||||
from asyncio import AbstractEventLoop
|
||||
|
||||
import psutil
|
||||
import websockets
|
||||
from pony.orm import commit, db_session, select
|
||||
|
||||
@@ -24,8 +27,10 @@ from MultiServer import (
|
||||
server_per_message_deflate_factory,
|
||||
)
|
||||
from Utils import restricted_loads, cache_argsless
|
||||
from NetUtils import GamesPackage
|
||||
from apmw.webhost.customserver.gamespackagecache import DBGamesPackageCache
|
||||
from .locker import Locker
|
||||
from .models import Command, GameDataPackage, Room, db
|
||||
from .models import Command, Room, db
|
||||
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
@@ -62,18 +67,39 @@ class DBCommandProcessor(ServerCommandProcessor):
|
||||
|
||||
class WebHostContext(Context):
|
||||
room_id: int
|
||||
video: dict[tuple[int, int], tuple[str, str]]
|
||||
main_loop: AbstractEventLoop
|
||||
static_server_data: StaticServerData
|
||||
|
||||
def __init__(self, static_server_data: dict, logger: logging.Logger):
|
||||
def __init__(
|
||||
self,
|
||||
static_server_data: StaticServerData,
|
||||
games_package_cache: DBGamesPackageCache,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
# static server data is used during _load_game_data to load required data,
|
||||
# without needing to import worlds system, which takes quite a bit of memory
|
||||
self.static_server_data = static_server_data
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1,
|
||||
40, True, "enabled", "enabled",
|
||||
"enabled", 0, 2, logger=logger)
|
||||
del self.static_server_data
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
super(WebHostContext, self).__init__(
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
40,
|
||||
True,
|
||||
"enabled",
|
||||
"enabled",
|
||||
"enabled",
|
||||
0,
|
||||
2,
|
||||
games_package_cache=games_package_cache,
|
||||
logger=logger,
|
||||
)
|
||||
self.tags = ["AP", "WebHost"]
|
||||
self.video = {}
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.static_server_data = static_server_data
|
||||
self.games_package_cache = games_package_cache
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
@@ -83,12 +109,6 @@ class WebHostContext(Context):
|
||||
except ImportError:
|
||||
self.logger.debug("Context destroyed")
|
||||
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
|
||||
setattr(self, key, value)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
async def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
@@ -115,45 +135,17 @@ class WebHostContext(Context):
|
||||
if room.last_port:
|
||||
self.port = room.last_port
|
||||
else:
|
||||
self.port = get_random_port()
|
||||
self.port = 0
|
||||
|
||||
multidata = self.decompress(room.seed.multidata)
|
||||
game_data_packages = {}
|
||||
return self._load(multidata, True)
|
||||
|
||||
static_gamespackage = self.gamespackage # this is shared across all rooms
|
||||
static_item_name_groups = self.item_name_groups
|
||||
static_location_name_groups = self.location_name_groups
|
||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||
missing_checksum = False
|
||||
|
||||
for game in list(multidata.get("datapackage", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
if "checksum" in game_data:
|
||||
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata and use static data
|
||||
# games package could be dropped from static data once all rooms embed data package
|
||||
del multidata["datapackage"][game]
|
||||
else:
|
||||
row = GameDataPackage.get(checksum=game_data["checksum"])
|
||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
|
||||
game_data_packages[game] = restricted_loads(row.data)
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||
else:
|
||||
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||
|
||||
if not game_data_packages and not missing_checksum:
|
||||
# all static -> use the static dicts directly
|
||||
self.gamespackage = static_gamespackage
|
||||
self.item_name_groups = static_item_name_groups
|
||||
self.location_name_groups = static_location_name_groups
|
||||
return self._load(multidata, game_data_packages, True)
|
||||
def _load_world_data(self):
|
||||
# Use static_server_data, but skip static data package since that is in cache anyway.
|
||||
# Also NOT importing worlds here!
|
||||
# FIXME: does this copy the non_hintable_names (also for games not part of the room)?
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.static_server_data["non_hintable_names"])
|
||||
del self.static_server_data # Not used past this point. Free memory.
|
||||
|
||||
def init_save(self, enabled: bool = True):
|
||||
self.saving = enabled
|
||||
@@ -172,7 +164,7 @@ class WebHostContext(Context):
|
||||
room.multisave = pickle.dumps(self.get_save())
|
||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
room.last_activity = datetime.datetime.utcnow()
|
||||
room.last_activity = Utils.utcnow()
|
||||
return True
|
||||
|
||||
def get_save(self) -> dict:
|
||||
@@ -181,38 +173,117 @@ class WebHostContext(Context):
|
||||
return d
|
||||
|
||||
|
||||
def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
class GameRangePorts(typing.NamedTuple):
|
||||
parsed_ports: list[range]
|
||||
weights: list[int]
|
||||
ephemeral_allowed: bool
|
||||
|
||||
|
||||
@functools.cache
|
||||
def parse_game_ports(game_ports: tuple[str | int, ...]) -> GameRangePorts:
|
||||
parsed_ports: list[range] = []
|
||||
weights: list[int] = []
|
||||
ephemeral_allowed = False
|
||||
total_length = 0
|
||||
|
||||
for item in game_ports:
|
||||
if isinstance(item, str) and "-" in item:
|
||||
start, end = map(int, item.split("-"))
|
||||
x = range(start, end + 1)
|
||||
total_length += len(x)
|
||||
weights.append(total_length)
|
||||
parsed_ports.append(x)
|
||||
elif int(item) == 0:
|
||||
ephemeral_allowed = True
|
||||
else:
|
||||
total_length += 1
|
||||
weights.append(total_length)
|
||||
num = int(item)
|
||||
parsed_ports.append(range(num, num + 1))
|
||||
|
||||
return GameRangePorts(parsed_ports, weights, ephemeral_allowed)
|
||||
|
||||
|
||||
def weighted_random(ranges: list[range], cum_weights: list[int]) -> int:
|
||||
[picked] = random.choices(ranges, cum_weights=cum_weights)
|
||||
return random.randrange(picked.start, picked.stop, picked.step)
|
||||
|
||||
|
||||
def create_random_port_socket(game_ports: tuple[str | int, ...], host: str) -> socket.socket:
|
||||
parsed_ports, weights, ephemeral_allowed = parse_game_ports(game_ports)
|
||||
used_ports = get_used_ports()
|
||||
i = 1024 if len(parsed_ports) > 0 else 0
|
||||
while i > 0:
|
||||
port_num = weighted_random(parsed_ports, weights)
|
||||
if port_num in used_ports:
|
||||
used_ports = get_used_ports()
|
||||
continue
|
||||
|
||||
i -= 0
|
||||
|
||||
try:
|
||||
return socket.create_server((host, port_num))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if ephemeral_allowed:
|
||||
return socket.create_server((host, 0))
|
||||
|
||||
raise OSError(98, "No available ports")
|
||||
|
||||
|
||||
def try_conns_per_process(p: psutil.Process) -> typing.Iterable[int]:
|
||||
try:
|
||||
return (c.laddr.port for c in p.net_connections("tcp4"))
|
||||
except psutil.AccessDenied:
|
||||
return ()
|
||||
|
||||
|
||||
def get_active_net_connections() -> typing.Iterable[int]:
|
||||
# Don't even try to check if system using AIX
|
||||
if psutil.AIX:
|
||||
return ()
|
||||
|
||||
try:
|
||||
return (c.laddr.port for c in psutil.net_connections("tcp4"))
|
||||
# raises AccessDenied when done on macOS
|
||||
except psutil.AccessDenied:
|
||||
# flatten the list of iterables
|
||||
return itertools.chain.from_iterable(map(
|
||||
# get the net connections of the process and then map its ports
|
||||
try_conns_per_process,
|
||||
# this method has caching handled by psutil
|
||||
psutil.process_iter(["net_connections"])
|
||||
))
|
||||
|
||||
|
||||
def get_used_ports():
|
||||
last_used_ports: tuple[frozenset[int], float] | None = getattr(get_used_ports, "last", None)
|
||||
t_hash = round(time.time() / 90) # cache for 90 seconds
|
||||
if last_used_ports is None or last_used_ports[1] != t_hash:
|
||||
last_used_ports = (frozenset(get_active_net_connections()), t_hash)
|
||||
setattr(get_used_ports, "last", last_used_ports)
|
||||
|
||||
return last_used_ports[0]
|
||||
|
||||
|
||||
class StaticServerData(typing.TypedDict, total=True):
|
||||
non_hintable_names: dict[str, typing.AbstractSet[str]]
|
||||
games_package: dict[str, GamesPackage]
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_static_server_data() -> dict:
|
||||
def get_static_server_data() -> StaticServerData:
|
||||
import worlds
|
||||
data = {
|
||||
|
||||
return {
|
||||
"non_hintable_names": {
|
||||
world_name: world.hint_blacklist
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"gamespackage": {
|
||||
world_name: {
|
||||
key: value
|
||||
for key, value in game_package.items()
|
||||
if key not in ("item_name_groups", "location_name_groups")
|
||||
}
|
||||
for world_name, game_package in worlds.network_data_package["games"].items()
|
||||
},
|
||||
"item_name_groups": {
|
||||
world_name: world.item_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"location_name_groups": {
|
||||
world_name: world.location_name_groups
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items()
|
||||
},
|
||||
"games_package": worlds.network_data_package["games"]
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def set_up_logging(room_id) -> logging.Logger:
|
||||
import os
|
||||
@@ -245,9 +316,19 @@ def tear_down_logging(room_id):
|
||||
del logging.Logger.manager.loggerDict[logger_name]
|
||||
|
||||
|
||||
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
|
||||
def run_server_process(
|
||||
name: str,
|
||||
ponyconfig: dict[str, typing.Any],
|
||||
static_server_data: StaticServerData,
|
||||
cert_file: typing.Optional[str],
|
||||
cert_key_file: typing.Optional[str],
|
||||
host: str,
|
||||
game_ports: typing.Iterable[str | int],
|
||||
rooms_to_run: multiprocessing.Queue,
|
||||
rooms_shutting_down: multiprocessing.Queue,
|
||||
) -> None:
|
||||
import gc
|
||||
|
||||
from setproctitle import setproctitle
|
||||
|
||||
setproctitle(name)
|
||||
@@ -263,6 +344,11 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
del resource, file_limit
|
||||
|
||||
# prime the data package cache with static data
|
||||
games_package_cache = DBGamesPackageCache(static_server_data["games_package"])
|
||||
# convert to tuple because its hashable
|
||||
game_ports = tuple(game_ports)
|
||||
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
@@ -270,8 +356,6 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
if "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||
|
||||
import gc
|
||||
|
||||
if not cert_file:
|
||||
def get_ssl_context():
|
||||
return None
|
||||
@@ -296,24 +380,30 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
with Locker(f"RoomLocker {room_id}"):
|
||||
try:
|
||||
logger = set_up_logging(room_id)
|
||||
ctx = WebHostContext(static_server_data, logger)
|
||||
ctx = WebHostContext(static_server_data, games_package_cache, logger)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
assert ctx.server is None
|
||||
try:
|
||||
if ctx.port != 0:
|
||||
try:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx),
|
||||
ctx.host,
|
||||
ctx.port,
|
||||
ssl=get_ssl_context(),
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
await ctx.server
|
||||
except OSError:
|
||||
ctx.port = 0
|
||||
if ctx.port == 0:
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx),
|
||||
ctx.host,
|
||||
ctx.port,
|
||||
sock=create_random_port_socket(game_ports, ctx.host),
|
||||
ssl=get_ssl_context(),
|
||||
extensions=[server_per_message_deflate_factory],
|
||||
)
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
@@ -367,8 +457,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
with db_session:
|
||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
||||
room = Room.get(id=room_id)
|
||||
room.last_activity = datetime.datetime.utcnow() - \
|
||||
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
room.last_activity = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
del room
|
||||
tear_down_logging(room_id)
|
||||
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||
@@ -389,7 +478,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
|
||||
|
||||
def run(self):
|
||||
while 1:
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
next_room = rooms_to_run.get(block=True, timeout=None)
|
||||
gc.collect()
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from datetime import timedelta, datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import render_template
|
||||
from pony.orm import count
|
||||
|
||||
from Utils import utcnow
|
||||
from WebHostLib import app, cache
|
||||
from .models import Room, Seed
|
||||
|
||||
@@ -10,6 +11,6 @@ from .models import Room, Seed
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@cache.cached(timeout=300) # cache has to appear under app route for caching to work
|
||||
def landing():
|
||||
rooms = count(room for room in Room if room.creation_time >= datetime.utcnow() - timedelta(days=7))
|
||||
seeds = count(seed for seed in Seed if seed.creation_time >= datetime.utcnow() - timedelta(days=7))
|
||||
rooms = count(room for room in Room if room.creation_time >= utcnow() - timedelta(days=7))
|
||||
seeds = count(seed for seed in Seed if seed.creation_time >= utcnow() - timedelta(days=7))
|
||||
return render_template("landing.html", rooms=rooms, seeds=seeds)
|
||||
|
||||
@@ -9,11 +9,12 @@ from flask import request, redirect, url_for, render_template, Response, session
|
||||
from pony.orm import count, commit, db_session
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister, World
|
||||
from . import app, cache
|
||||
from .markdown import render_markdown
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
from Utils import title_sorted
|
||||
from Utils import title_sorted, utcnow
|
||||
|
||||
class WebWorldTheme(StrEnum):
|
||||
DIRT = "dirt"
|
||||
@@ -233,11 +234,12 @@ def host_room(room: UUID):
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
now = utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||
|
||||
should_refresh = (
|
||||
(not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout)
|
||||
)
|
||||
if now - room.last_activity > datetime.timedelta(minutes=1):
|
||||
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
|
||||
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
|
||||
|
||||
@@ -2,6 +2,8 @@ from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
|
||||
|
||||
from Utils import utcnow
|
||||
|
||||
db = Database()
|
||||
|
||||
STATE_QUEUED = 0
|
||||
@@ -20,8 +22,8 @@ class Slot(db.Entity):
|
||||
|
||||
class Room(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
last_activity: datetime = Required(datetime, default=lambda: utcnow(), index=True)
|
||||
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
|
||||
owner = Required(UUID, index=True)
|
||||
commands = Set('Command')
|
||||
seed = Required('Seed', index=True)
|
||||
@@ -38,7 +40,7 @@ class Seed(db.Entity):
|
||||
rooms = Set(Room)
|
||||
multidata = Required(bytes, lazy=True)
|
||||
owner = Required(UUID, index=True)
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page
|
||||
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
|
||||
slots = Set(Slot)
|
||||
spoiler = Optional(LongStr, lazy=True)
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -33,6 +33,17 @@ html{
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#landing-header h5 {
|
||||
color: #ffffff;
|
||||
font-style: italic;
|
||||
font-size: 28px;
|
||||
margin-top: 15px;
|
||||
margin-bottom: -43px;
|
||||
text-shadow: 1px 1px 7px #000000;
|
||||
font-kerning: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#landing-links{
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@@ -13,7 +13,3 @@
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def stats():
|
||||
from worlds import network_data_package
|
||||
known_games = set(network_data_package["games"])
|
||||
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
|
||||
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH * 2, height=1000)
|
||||
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500)
|
||||
|
||||
total_games, games_played = get_db_data(known_games)
|
||||
days = sorted(games_played)
|
||||
@@ -96,7 +96,7 @@ def stats():
|
||||
total = sum(total_games.values())
|
||||
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
||||
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
||||
sizing_mode="scale_both", width=PLOT_WIDTH * 2, height=1000, x_range=(-0.5, 1.2))
|
||||
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
|
||||
pie.axis.visible = False
|
||||
pie.xgrid.visible = False
|
||||
pie.ygrid.visible = False
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div id="landing-wrapper">
|
||||
<div id="landing-header">
|
||||
<img id="landing-logo" src="static/static/branding/landing-logo.png" alt="Archipelago Logo" />
|
||||
<h4>multiworld multi-game randomizer</h4>
|
||||
<h4>multiworld multi-game randomizer</h4><h5>beta</h5>
|
||||
</div>
|
||||
<div id="landing-links">
|
||||
<a href="/games" id="far-left-button">Supported<br />Games</a>
|
||||
@@ -35,7 +35,8 @@
|
||||
</div>
|
||||
<div id="landing" class="grass-island">
|
||||
<div id="landing-body">
|
||||
<p id="first-line">Welcome to Archipelago!</p>
|
||||
<p id="first-line">Welcome to Archipelago Beta!</p>
|
||||
<p>For the stable version, visit <a href="//archipelago.gg">Archipelago.gg</a>!</p>
|
||||
<p>
|
||||
This is a cross-game modification system which randomizes different games, then uses the result to
|
||||
build a single unified multi-player game. Items from one game may be present in another, and
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="user-message">This is the beta site! For the stable version, visit <a href="https://archipelago.gg">Archipelago.gg</a>!</div>
|
||||
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div id="charts-wrapper">
|
||||
{% for chart in charts %}
|
||||
<div class="chart-container{% if loop.index0 < 2 %} full-width{% endif %}">
|
||||
<div class="chart-container">
|
||||
{{ chart|indent(16)|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -10,7 +10,7 @@ from werkzeug.exceptions import abort
|
||||
|
||||
from MultiServer import Context, get_saving_second
|
||||
from NetUtils import ClientStatus, Hint, NetworkItem, NetworkSlot, SlotType
|
||||
from Utils import restricted_loads, KeyedDefaultDict
|
||||
from Utils import restricted_loads, KeyedDefaultDict, utcnow
|
||||
from . import app, cache
|
||||
from .models import GameDataPackage, Room
|
||||
|
||||
@@ -273,9 +273,10 @@ class TrackerData:
|
||||
Does not include players who have no activity recorded.
|
||||
"""
|
||||
last_activity: Dict[TeamPlayer, datetime.timedelta] = {}
|
||||
now = datetime.datetime.utcnow()
|
||||
now = utcnow()
|
||||
for (team, player), timestamp in self._multisave.get("client_activity_timers", []):
|
||||
last_activity[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
|
||||
from_timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).replace(tzinfo=None)
|
||||
last_activity[team, player] = now - from_timestamp
|
||||
|
||||
return last_activity
|
||||
|
||||
|
||||
0
apmw/__init__.py
Normal file
0
apmw/__init__.py
Normal file
0
apmw/multiserver/__init__.py
Normal file
0
apmw/multiserver/__init__.py
Normal file
96
apmw/multiserver/gamespackagecache.py
Normal file
96
apmw/multiserver/gamespackagecache.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import typing as t
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from NetUtils import GamesPackage
|
||||
|
||||
GameAndChecksum = tuple[str, str | None]
|
||||
ItemNameGroups = dict[str, list[str]]
|
||||
LocationNameGroups = dict[str, list[str]]
|
||||
|
||||
|
||||
K = t.TypeVar("K")
|
||||
V = t.TypeVar("V")
|
||||
|
||||
|
||||
class DictLike(dict[K, V]):
|
||||
__slots__ = ("__weakref__",)
|
||||
|
||||
|
||||
class GamesPackageCache:
|
||||
# NOTE: this uses 3 separate collections because unpacking the get() result would end the container lifetime
|
||||
_reduced_games_packages: WeakValueDictionary[GameAndChecksum, GamesPackage]
|
||||
"""Does not include item_name_groups nor location_name_groups"""
|
||||
_item_name_groups: WeakValueDictionary[GameAndChecksum, dict[str, list[str]]]
|
||||
_location_name_groups: WeakValueDictionary[GameAndChecksum, dict[str, list[str]]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._reduced_games_packages = WeakValueDictionary()
|
||||
self._item_name_groups = WeakValueDictionary()
|
||||
self._location_name_groups = WeakValueDictionary()
|
||||
|
||||
def _get(
|
||||
self,
|
||||
cache_key: GameAndChecksum,
|
||||
) -> tuple[GamesPackage | None, ItemNameGroups | None, LocationNameGroups | None]:
|
||||
if cache_key[1] is None:
|
||||
return None, None, None
|
||||
return (
|
||||
self._reduced_games_packages.get(cache_key, None),
|
||||
self._item_name_groups.get(cache_key, None),
|
||||
self._location_name_groups.get(cache_key, None),
|
||||
)
|
||||
|
||||
def get(
|
||||
self,
|
||||
game: str,
|
||||
full_games_package: GamesPackage,
|
||||
) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
|
||||
"""Loads and caches embedded data package provided by multidata"""
|
||||
cache_key = (game, full_games_package.get("checksum", None))
|
||||
cached_reduced_games_package, cached_item_name_groups, cached_location_name_groups = self._get(cache_key)
|
||||
|
||||
if cached_reduced_games_package is None:
|
||||
cached_reduced_games_package = t.cast(
|
||||
t.Any,
|
||||
DictLike(
|
||||
{
|
||||
"item_name_to_id": full_games_package["item_name_to_id"],
|
||||
"location_name_to_id": full_games_package["location_name_to_id"],
|
||||
"checksum": full_games_package.get("checksum", None),
|
||||
}
|
||||
),
|
||||
)
|
||||
if cache_key[1] is not None: # only cache if checksum is available
|
||||
self._reduced_games_packages[cache_key] = cached_reduced_games_package
|
||||
|
||||
if cached_item_name_groups is None:
|
||||
# optimize strings to be references instead of copies
|
||||
item_names = {name: name for name in cached_reduced_games_package["item_name_to_id"].keys()}
|
||||
cached_item_name_groups = DictLike(
|
||||
{
|
||||
group_name: [item_names.get(item_name, item_name) for item_name in group_items]
|
||||
for group_name, group_items in full_games_package["item_name_groups"].items()
|
||||
}
|
||||
)
|
||||
if cache_key[1] is not None: # only cache if checksum is available
|
||||
self._item_name_groups[cache_key] = cached_item_name_groups
|
||||
|
||||
if cached_location_name_groups is None:
|
||||
# optimize strings to be references instead of copies
|
||||
location_names = {name: name for name in cached_reduced_games_package["location_name_to_id"].keys()}
|
||||
cached_location_name_groups = DictLike(
|
||||
{
|
||||
group_name: [location_names.get(location_name, location_name) for location_name in group_locations]
|
||||
for group_name, group_locations in full_games_package.get("location_name_groups", {}).items()
|
||||
}
|
||||
)
|
||||
if cache_key[1] is not None: # only cache if checksum is available
|
||||
self._location_name_groups[cache_key] = cached_location_name_groups
|
||||
|
||||
return cached_reduced_games_package, cached_item_name_groups, cached_location_name_groups
|
||||
|
||||
def get_static(self, game: str) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
|
||||
"""Loads legacy data package from installed worlds"""
|
||||
import worlds
|
||||
|
||||
return self.get(game, worlds.network_data_package["games"][game])
|
||||
0
apmw/webhost/__init__.py
Normal file
0
apmw/webhost/__init__.py
Normal file
0
apmw/webhost/customserver/__init__.py
Normal file
0
apmw/webhost/customserver/__init__.py
Normal file
42
apmw/webhost/customserver/gamespackagecache.py
Normal file
42
apmw/webhost/customserver/gamespackagecache.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from typing_extensions import override
|
||||
|
||||
from NetUtils import GamesPackage
|
||||
from Utils import restricted_loads
|
||||
from apmw.multiserver.gamespackagecache import GamesPackageCache, ItemNameGroups, LocationNameGroups
|
||||
|
||||
|
||||
class DBGamesPackageCache(GamesPackageCache):
|
||||
_static: dict[str, tuple[GamesPackage, ItemNameGroups, LocationNameGroups]]
|
||||
|
||||
def __init__(self, static_games_package: dict[str, GamesPackage]) -> None:
|
||||
super().__init__()
|
||||
self._static = {
|
||||
game: GamesPackageCache.get(self, game, games_package)
|
||||
for game, games_package in static_games_package.items()
|
||||
}
|
||||
|
||||
@override
|
||||
def get(
|
||||
self,
|
||||
game: str,
|
||||
full_games_package: GamesPackage,
|
||||
) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
|
||||
# for games started on webhost, full_games_package is likely unpopulated and only has the checksum field
|
||||
cache_key = (game, full_games_package.get("checksum", None))
|
||||
cached = self._get(cache_key)
|
||||
if any(value is None for value in cached):
|
||||
if "checksum" not in full_games_package:
|
||||
return super().get(game, full_games_package) # no checksum, assume fully populated
|
||||
|
||||
from WebHostLib.models import GameDataPackage
|
||||
|
||||
row: GameDataPackage | None = GameDataPackage.get(checksum=full_games_package["checksum"])
|
||||
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8 ...
|
||||
return super().get(game, restricted_loads(row.data))
|
||||
return super().get(game, full_games_package) # ... in which case full_games_package should be populated
|
||||
|
||||
return cached # type: ignore # mypy doesn't understand any value is None
|
||||
|
||||
@override
|
||||
def get_static(self, game: str) -> tuple[GamesPackage, ItemNameGroups, LocationNameGroups]:
|
||||
return self._static[game]
|
||||
@@ -41,16 +41,8 @@ http {
|
||||
# server_name example.com www.example.com;
|
||||
|
||||
keepalive_timeout 5;
|
||||
|
||||
# path for static files
|
||||
root /app/WebHostLib;
|
||||
|
||||
|
||||
location / {
|
||||
# checks for static file, if not found proxy to app
|
||||
try_files $uri @proxy_to_app;
|
||||
}
|
||||
|
||||
location @proxy_to_app {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
@@ -60,5 +52,15 @@ http {
|
||||
|
||||
proxy_pass http://app_server;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
root /app/WebHostLib/;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
location = /favicon.ico {
|
||||
alias /app/WebHostLib/static/static/favicon.ico;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
# Mega Man 2
|
||||
/worlds/mm2/ @Silvris
|
||||
|
||||
# Mega Man 3
|
||||
/worlds/mm3/ @Silvris
|
||||
|
||||
# MegaMan Battle Network 3
|
||||
/worlds/mmbn3/ @digiholic
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@ The world is your game integration for the Archipelago generator, webhost, and m
|
||||
information necessary for creating the items and locations to be randomized, the logic for item placement, the
|
||||
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
|
||||
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
|
||||
repository and creating a new world package in `/worlds/`.
|
||||
repository and creating a new world package in `/worlds/` (see [running from source](/docs/running%20from%20source.md)
|
||||
for setup).
|
||||
|
||||
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
|
||||
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
||||
|
||||
@@ -46,8 +46,8 @@ which is the correct way to package your `.apworld` as a world developer. Do not
|
||||
|
||||
### "Build APWorlds" Launcher Component
|
||||
|
||||
In the Archipelago Launcher, there is a "Build APWorlds" component that will package all world folders to `.apworld`,
|
||||
and add `archipelago.json` manifest files to them.
|
||||
In the Archipelago Launcher (on [source only](/docs/running%20from%20source.md)), there is a "Build APWorlds"
|
||||
component that will package all world folders to `.apworld`, and add `archipelago.json` manifest files to them.
|
||||
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
|
||||
The `archipelago.json` file in each .apworld will automatically include the appropriate
|
||||
`version` and `compatible_version`.
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
# Web hosting port
|
||||
#PORT: 80
|
||||
|
||||
# Ports used for game hosting. Values can be specific ports, port ranges or both. Default is: [49152-65535, 0]
|
||||
# Zero means it will use a random free port if there is no port in the next 1024 randomly chosen ports from the range
|
||||
# Examples of valid values: [40000-41000, 49152-65535]
|
||||
# If ports within the range(s) are already in use, the WebHost will fallback to the default [49152-65535, 0] range.
|
||||
#GAME_PORTS: [49152-65535, 0]
|
||||
|
||||
# Place where uploads go.
|
||||
#UPLOAD_FOLDER: uploads
|
||||
|
||||
|
||||
@@ -491,9 +491,10 @@ class MyGameWorld(World):
|
||||
base_id = 1234
|
||||
# instead of dynamic numbering, IDs could be part of data
|
||||
|
||||
# The following two dicts are required for the generation to know which
|
||||
# items exist. They could be generated from json or something else. They can
|
||||
# include events, but don't have to since events will be placed manually.
|
||||
# The following two dicts are required for the generation to know which items exist.
|
||||
# They can be generated with arbitrary code during world load, but keep in mind that
|
||||
# anything expensive (e.g. parsing non-python data files) will delay world loading.
|
||||
# They can include events, but don't have to since events will be placed manually.
|
||||
item_name_to_id = {name: id for
|
||||
id, name in enumerate(mygame_items, base_id)}
|
||||
location_name_to_id = {name: id for
|
||||
|
||||
@@ -186,9 +186,20 @@ class ERPlacementState:
|
||||
self.pairings = []
|
||||
self.world = world
|
||||
self.coupled = coupled
|
||||
self.collection_state = world.multiworld.get_all_state(False, True)
|
||||
self.entrance_lookup = entrance_lookup
|
||||
|
||||
# Construct an 'all state', similar to MultiWorld.get_all_state(), but only for the world which is having its
|
||||
# entrances randomized.
|
||||
single_player_all_state = CollectionState(world.multiworld, True)
|
||||
player = world.player
|
||||
for item in world.multiworld.itempool:
|
||||
if item.player == player:
|
||||
world.collect(single_player_all_state, item)
|
||||
for item in world.get_pre_fill_items():
|
||||
world.collect(single_player_all_state, item)
|
||||
single_player_all_state.sweep_for_advancements(world.get_locations())
|
||||
self.collection_state = single_player_all_state
|
||||
|
||||
@property
|
||||
def placed_regions(self) -> set[Region]:
|
||||
return self.collection_state.reachable_regions[self.world.player]
|
||||
@@ -226,7 +237,7 @@ class ERPlacementState:
|
||||
copied_state.blocked_connections[self.world.player].remove(source_exit)
|
||||
copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits)
|
||||
copied_state.update_reachable_regions(self.world.player)
|
||||
copied_state.sweep_for_advancements()
|
||||
copied_state.sweep_for_advancements(self.world.get_locations())
|
||||
# test that at there are newly reachable randomized exits that are ACTUALLY reachable
|
||||
available_randomized_exits = copied_state.blocked_connections[self.world.player]
|
||||
for _exit in available_randomized_exits:
|
||||
@@ -402,7 +413,7 @@ def randomize_entrances(
|
||||
placed_exits, paired_entrances = er_state.connect(source_exit, target_entrance)
|
||||
# propagate new connections
|
||||
er_state.collection_state.update_reachable_regions(world.player)
|
||||
er_state.collection_state.sweep_for_advancements()
|
||||
er_state.collection_state.sweep_for_advancements(world.get_locations())
|
||||
if on_connect:
|
||||
change = on_connect(er_state, placed_exits, paired_entrances)
|
||||
if change:
|
||||
|
||||
@@ -213,6 +213,11 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Options import Choice, TextChoice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Utils import restricted_dumps
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
@@ -16,6 +16,29 @@ class TestOptions(unittest.TestCase):
|
||||
with self.subTest(game=gamename, option=option_key):
|
||||
self.assertTrue(option.__doc__)
|
||||
|
||||
def test_option_defaults(self):
|
||||
"""Test that defaults for submitted options are valid."""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
with self.subTest(game=gamename, option=option_key):
|
||||
if issubclass(option, TextChoice):
|
||||
self.assertTrue(option.default in option.name_lookup,
|
||||
f"Default value {option.default} for TextChoice option {option.__name__} in"
|
||||
f" {gamename} does not resolve to a listed value!"
|
||||
)
|
||||
# Standard "can default generate" test
|
||||
err_raised = None
|
||||
try:
|
||||
option.from_any(option.default)
|
||||
except Exception as ex:
|
||||
err_raised = ex
|
||||
self.assertIsNone(err_raised,
|
||||
f"Default value {option.default} for option {option.__name__} in {gamename}"
|
||||
f" is not valid! Exception: {err_raised}"
|
||||
)
|
||||
|
||||
|
||||
def test_options_are_not_set_by_world(self):
|
||||
"""Test that options attribute is not already set"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
|
||||
@@ -6,6 +6,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Iterable, Optional, cast
|
||||
|
||||
from Utils import utcnow
|
||||
from WebHostLib import to_python
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -133,7 +134,7 @@ def stop_room(app_client: "FlaskClient",
|
||||
room_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
simulate_idle: bool = True) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from time import sleep
|
||||
|
||||
from pony.orm import db_session
|
||||
@@ -151,10 +152,11 @@ def stop_room(app_client: "FlaskClient",
|
||||
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
now = utcnow()
|
||||
if simulate_idle:
|
||||
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
|
||||
new_last_activity = now - timedelta(seconds=room.timeout + 5)
|
||||
else:
|
||||
new_last_activity = datetime.utcnow() - timedelta(days=3)
|
||||
new_last_activity = now - timedelta(days=3)
|
||||
room.last_activity = new_last_activity
|
||||
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
|
||||
if address:
|
||||
@@ -188,6 +190,7 @@ def stop_room(app_client: "FlaskClient",
|
||||
if address:
|
||||
room.timeout = original_timeout
|
||||
room.last_activity = new_last_activity
|
||||
room.commands.clear() # make sure there is no leftover /exit
|
||||
print("timeout restored")
|
||||
|
||||
|
||||
|
||||
0
test/multiserver/__init__.py
Normal file
0
test/multiserver/__init__.py
Normal file
132
test/multiserver/test_gamespackage_cache.py
Normal file
132
test/multiserver/test_gamespackage_cache.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import typing as t
|
||||
from copy import deepcopy
|
||||
from unittest import TestCase
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
import NetUtils
|
||||
from NetUtils import GamesPackage
|
||||
from apmw.multiserver.gamespackagecache import GamesPackageCache
|
||||
|
||||
|
||||
class GamesPackageCacheTest(TestCase):
|
||||
cache: GamesPackageCache
|
||||
any_game: t.ClassVar[str] = "APQuest"
|
||||
example_games_package: GamesPackage = {
|
||||
"item_name_to_id": {"Item 1": 1},
|
||||
"item_name_groups": {"Everything": ["Item 1"]},
|
||||
"location_name_to_id": {"Location 1": 1},
|
||||
"location_name_groups": {"Everywhere": ["Location 1"]},
|
||||
"checksum": "1234",
|
||||
}
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
self.cache = GamesPackageCache()
|
||||
|
||||
def test_get_static_is_same(self) -> None:
|
||||
"""Tests that get_static returns the same objects twice"""
|
||||
reduced_games_package1, item_name_groups1, location_name_groups1 = self.cache.get_static(self.any_game)
|
||||
reduced_games_package2, item_name_groups2, location_name_groups2 = self.cache.get_static(self.any_game)
|
||||
self.assertIs(reduced_games_package1, reduced_games_package2)
|
||||
self.assertIs(item_name_groups1, item_name_groups2)
|
||||
self.assertIs(location_name_groups1, location_name_groups2)
|
||||
|
||||
def test_get_static_data_format(self) -> None:
|
||||
"""Tests that get_static returns data in the correct format"""
|
||||
reduced_games_package, item_name_groups, location_name_groups = self.cache.get_static(self.any_game)
|
||||
self.assertTrue(reduced_games_package["checksum"])
|
||||
self.assertTrue(reduced_games_package["item_name_to_id"])
|
||||
self.assertTrue(reduced_games_package["location_name_to_id"])
|
||||
self.assertNotIn("item_name_groups", reduced_games_package)
|
||||
self.assertNotIn("location_name_groups", reduced_games_package)
|
||||
self.assertTrue(item_name_groups["Everything"])
|
||||
self.assertTrue(location_name_groups["Everywhere"])
|
||||
|
||||
def test_get_static_is_serializable(self) -> None:
|
||||
"""Tests that get_static returns data that can be serialized"""
|
||||
NetUtils.encode(self.cache.get_static(self.any_game))
|
||||
|
||||
def test_get_static_missing_raises(self) -> None:
|
||||
"""Tests that get_static raises KeyError if the world is missing"""
|
||||
with self.assertRaises(KeyError):
|
||||
_ = self.cache.get_static("Does not exist")
|
||||
|
||||
def test_eviction(self) -> None:
|
||||
"""Tests that unused items get evicted from cache"""
|
||||
game_name = "Test"
|
||||
before_add = len(self.cache._reduced_games_packages)
|
||||
data = self.cache.get(game_name, self.example_games_package)
|
||||
self.assertTrue(data)
|
||||
self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
|
||||
|
||||
del data
|
||||
if len(self.cache._reduced_games_packages) != before_add: # gc.collect() may not even be required
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
|
||||
self.assertEqual(before_add, len(self.cache._reduced_games_packages))
|
||||
|
||||
def test_get_required_field(self) -> None:
|
||||
"""Tests that missing required field raises a KeyError"""
|
||||
for field in ("item_name_to_id", "location_name_to_id", "item_name_groups"):
|
||||
with self.subTest(field=field):
|
||||
games_package = deepcopy(self.example_games_package)
|
||||
del games_package[field] # type: ignore
|
||||
with self.assertRaises(KeyError):
|
||||
_ = self.cache.get(self.any_game, games_package)
|
||||
|
||||
def test_get_optional_properties(self) -> None:
|
||||
"""Tests that missing optional field works"""
|
||||
for field in ("checksum", "location_name_groups"):
|
||||
with self.subTest(field=field):
|
||||
games_package = deepcopy(self.example_games_package)
|
||||
del games_package[field] # type: ignore
|
||||
_, item_name_groups, location_name_groups = self.cache.get(self.any_game, games_package)
|
||||
self.assertTrue(item_name_groups)
|
||||
self.assertEqual(field != "location_name_groups", bool(location_name_groups))
|
||||
|
||||
def test_item_name_deduplication(self) -> None:
|
||||
n = 1
|
||||
s1 = f"Item {n}"
|
||||
s2 = f"Item {n}"
|
||||
# check if the deduplication is actually gonna do anything
|
||||
self.assertIsNot(s1, s2)
|
||||
self.assertEqual(s1, s2)
|
||||
# do the thing
|
||||
game_name = "Test"
|
||||
games_package: GamesPackage = {
|
||||
"item_name_to_id": {s1: n},
|
||||
"item_name_groups": {"Everything": [s2]},
|
||||
"location_name_to_id": {},
|
||||
"location_name_groups": {},
|
||||
"checksum": "1234",
|
||||
}
|
||||
reduced_games_package, item_name_groups, location_name_groups = self.cache.get(game_name, games_package)
|
||||
self.assertIs(
|
||||
next(iter(reduced_games_package["item_name_to_id"].keys())),
|
||||
item_name_groups["Everything"][0],
|
||||
)
|
||||
|
||||
def test_location_name_deduplication(self) -> None:
|
||||
n = 1
|
||||
s1 = f"Location {n}"
|
||||
s2 = f"Location {n}"
|
||||
# check if the deduplication is actually gonna do anything
|
||||
self.assertIsNot(s1, s2)
|
||||
self.assertEqual(s1, s2)
|
||||
# do the thing
|
||||
game_name = "Test"
|
||||
games_package: GamesPackage = {
|
||||
"item_name_to_id": {},
|
||||
"item_name_groups": {},
|
||||
"location_name_to_id": {s1: n},
|
||||
"location_name_groups": {"Everywhere": [s2]},
|
||||
"checksum": "1234",
|
||||
}
|
||||
reduced_games_package, item_name_groups, location_name_groups = self.cache.get(game_name, games_package)
|
||||
self.assertIs(
|
||||
next(iter(reduced_games_package["location_name_to_id"].keys())),
|
||||
location_name_groups["Everywhere"][0],
|
||||
)
|
||||
86
test/webhost/test_port_allocation.py
Normal file
86
test/webhost/test_port_allocation.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from Utils import is_macos
|
||||
from WebHostLib.customserver import parse_game_ports, create_random_port_socket, get_used_ports
|
||||
|
||||
ci = bool(os.environ.get("CI"))
|
||||
|
||||
|
||||
class TestPortAllocating(unittest.TestCase):
|
||||
def test_parse_game_ports(self) -> None:
|
||||
"""Ensure that game ports with ranges are parsed correctly"""
|
||||
val = parse_game_ports(("1000-2000", "2000-5000", "1000-2000", 20, 40, "20", "0"))
|
||||
|
||||
self.assertListEqual(val.parsed_ports,
|
||||
[range(1000, 2001), range(2000, 5001), range(1000, 2001), range(20, 21), range(40, 41),
|
||||
range(20, 21)], "The parsed game ports are not the expected values")
|
||||
self.assertTrue(val.ephemeral_allowed, "The ephemeral allowed flag is not set even though it was passed")
|
||||
self.assertListEqual(val.weights, [1001, 4002, 5003, 5004, 5005, 5006],
|
||||
"Cumulative weights are not the expected value")
|
||||
|
||||
val = parse_game_ports(())
|
||||
self.assertListEqual(val.parsed_ports, [], "Empty list of game port returned something")
|
||||
self.assertFalse(val.ephemeral_allowed, "Empty list returned that ephemeral is allowed")
|
||||
|
||||
val = parse_game_ports((0,))
|
||||
self.assertListEqual(val.parsed_ports, [], "Empty list of ranges returned something")
|
||||
self.assertTrue(val.ephemeral_allowed, "List with just 0 is not allowing ephemeral ports")
|
||||
|
||||
val = parse_game_ports((1,))
|
||||
self.assertEqual(val.parsed_ports, [range(1, 2)], "Parsed ports doesn't contain the expected values")
|
||||
self.assertFalse(val.ephemeral_allowed, "List with just single port returned that ephemeral is allowed")
|
||||
|
||||
def test_parse_game_port_errors(self) -> None:
|
||||
"""Ensure that game ports with incorrect values raise the expected error"""
|
||||
with self.assertRaises(ValueError, msg="Negative numbers didn't get interpreted as an invalid range"):
|
||||
parse_game_ports(tuple("-50215"))
|
||||
with self.assertRaises(ValueError, msg="Text got interpreted as a valid number"):
|
||||
parse_game_ports(tuple("dwafawg"))
|
||||
with self.assertRaises(
|
||||
ValueError,
|
||||
msg="A range with an extra dash at the end didn't get interpreted as an invalid number because of it's end dash"
|
||||
):
|
||||
parse_game_ports(tuple("20-21215-"))
|
||||
with self.assertRaises(ValueError, msg="Text got interpreted as a valid number for the start of a range"):
|
||||
parse_game_ports(tuple("f-21215"))
|
||||
|
||||
def test_random_port_socket_edge_cases(self) -> None:
|
||||
"""Verify if edge cases on creation of random port socket is working fine"""
|
||||
# Try giving an empty tuple and fail over it
|
||||
with self.assertRaises(OSError) as err:
|
||||
create_random_port_socket(tuple(), "127.0.0.1")
|
||||
self.assertEqual(err.exception.errno, 98, "Raised an unexpected error code")
|
||||
self.assertEqual(err.exception.strerror, "No available ports", "Raised an unexpected error string")
|
||||
|
||||
# Try only having ephemeral ports enabled
|
||||
try:
|
||||
create_random_port_socket(("0",), "127.0.0.1").close()
|
||||
except OSError as err:
|
||||
self.assertEqual(err.errno, 98, "Raised an unexpected error code")
|
||||
# If it returns our error string that means something is wrong with our code
|
||||
self.assertNotEqual(err.strerror, "No available ports",
|
||||
"Raised an unexpected error string")
|
||||
|
||||
@unittest.skipUnless(ci, "can't guarantee free ports outside of CI")
|
||||
def test_random_port_socket(self) -> None:
|
||||
"""Verify if returned sockets use the correct port ranges"""
|
||||
sockets = []
|
||||
for _ in range(6):
|
||||
socket = create_random_port_socket(("8080-8085",), "127.0.0.1")
|
||||
sockets.append(socket)
|
||||
_, port = socket.getsockname()
|
||||
self.assertIn(port, range(8080, 8086), "Port of socket was not inside the expected range")
|
||||
for s in sockets:
|
||||
s.close()
|
||||
|
||||
sockets.clear()
|
||||
length = 5_000 if is_macos else (30_000 - len(get_used_ports()))
|
||||
for _ in range(length):
|
||||
socket = create_random_port_socket(("30000-65535",), "127.0.0.1")
|
||||
sockets.append(socket)
|
||||
_, port = socket.getsockname()
|
||||
self.assertIn(port, range(30_000, 65536), "Port of socket was not inside the expected range")
|
||||
|
||||
for s in sockets:
|
||||
s.close()
|
||||
0
test/webhost_customserver/__init__.py
Normal file
0
test/webhost_customserver/__init__.py
Normal file
147
test/webhost_customserver/test_gamespackage_cache.py
Normal file
147
test/webhost_customserver/test_gamespackage_cache.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import typing as t
|
||||
from copy import deepcopy
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from test.multiserver.test_gamespackage_cache import GamesPackageCacheTest
|
||||
|
||||
import Utils
|
||||
from NetUtils import GamesPackage
|
||||
from apmw.webhost.customserver.gamespackagecache import DBGamesPackageCache
|
||||
|
||||
|
||||
class FakeGameDataPackage:
|
||||
_rows: "t.ClassVar[dict[str, FakeGameDataPackage]]" = {}
|
||||
data: bytes
|
||||
|
||||
@classmethod
|
||||
def get(cls, checksum: str) -> "FakeGameDataPackage | None":
|
||||
return cls._rows.get(checksum, None)
|
||||
|
||||
@classmethod
|
||||
def add(cls, checksum: str, full_games_package: GamesPackage) -> None:
|
||||
row = FakeGameDataPackage()
|
||||
row.data = Utils.restricted_dumps(full_games_package)
|
||||
cls._rows[checksum] = row
|
||||
|
||||
|
||||
class DBGamesPackageCacheTest(GamesPackageCacheTest):
|
||||
cache: DBGamesPackageCache
|
||||
any_game: t.ClassVar[str] = "My Game"
|
||||
static_data: t.ClassVar[dict[str, GamesPackage]] = { # noqa: pycharm doesn't understand this
|
||||
"My Game": {
|
||||
"item_name_to_id": {"Item 1": 1},
|
||||
"location_name_to_id": {"Location 1": 1},
|
||||
"item_name_groups": {"Everything": ["Item 1"]},
|
||||
"location_name_groups": {"Everywhere": ["Location 1"]},
|
||||
"checksum": "2345",
|
||||
}
|
||||
}
|
||||
orig_db_type: t.ClassVar[type]
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
import WebHostLib.models
|
||||
|
||||
cls.orig_db_type = WebHostLib.models.GameDataPackage
|
||||
WebHostLib.models.GameDataPackage = FakeGameDataPackage # type: ignore
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
self.cache = DBGamesPackageCache(self.static_data)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
import WebHostLib.models
|
||||
|
||||
WebHostLib.models.GameDataPackage = cls.orig_db_type # type: ignore
|
||||
|
||||
def assert_conversion(
|
||||
self,
|
||||
full_games_package: GamesPackage,
|
||||
reduced_games_package: dict[str, t.Any],
|
||||
item_name_groups: dict[str, t.Any],
|
||||
location_name_groups: dict[str, t.Any],
|
||||
) -> None:
|
||||
for key in ("item_name_to_id", "location_name_to_id", "checksum"):
|
||||
if key in full_games_package:
|
||||
self.assertEqual(reduced_games_package[key], full_games_package[key]) # noqa: pycharm
|
||||
self.assertEqual(item_name_groups, full_games_package["item_name_groups"])
|
||||
self.assertEqual(location_name_groups, full_games_package["location_name_groups"])
|
||||
|
||||
def assert_static_conversion(
|
||||
self,
|
||||
full_games_package: GamesPackage,
|
||||
reduced_games_package: dict[str, t.Any],
|
||||
item_name_groups: dict[str, t.Any],
|
||||
location_name_groups: dict[str, t.Any],
|
||||
) -> None:
|
||||
self.assert_conversion(full_games_package, reduced_games_package, item_name_groups, location_name_groups)
|
||||
for key in ("item_name_to_id", "location_name_to_id", "checksum"):
|
||||
self.assertIs(reduced_games_package[key], full_games_package[key]) # noqa: pycharm
|
||||
|
||||
def test_get_static_contents(self) -> None:
|
||||
"""Tests that get_static returns the correct data"""
|
||||
reduced_games_package, item_name_groups, location_name_groups = self.cache.get_static(self.any_game)
|
||||
for key in ("item_name_to_id", "location_name_to_id", "checksum"):
|
||||
self.assertIs(reduced_games_package[key], self.static_data[self.any_game][key]) # noqa: pycharm
|
||||
self.assertEqual(item_name_groups, self.static_data[self.any_game]["item_name_groups"])
|
||||
self.assertEqual(location_name_groups, self.static_data[self.any_game]["location_name_groups"])
|
||||
|
||||
def test_static_not_evicted(self) -> None:
|
||||
"""Tests that static data is not evicted from cache during gc"""
|
||||
import gc
|
||||
|
||||
game_name = next(iter(self.static_data.keys()))
|
||||
ids = [id(o) for o in self.cache.get_static(game_name)]
|
||||
gc.collect()
|
||||
self.assertEqual(ids, [id(o) for o in self.cache.get_static(game_name)])
|
||||
|
||||
def test_get_is_static(self) -> None:
|
||||
"""Tests that a get with correct checksum return the static items"""
|
||||
# NOTE: this is only true for the DB cache, not the "regular" one, since we want to avoid loading worlds there
|
||||
cks: GamesPackage = {"checksum": self.static_data[self.any_game]["checksum"]} # noqa: pycharm doesn't like this
|
||||
reduced_games_package1, item_name_groups1, location_name_groups1 = self.cache.get(self.any_game, cks)
|
||||
reduced_games_package2, item_name_groups2, location_name_groups2 = self.cache.get_static(self.any_game)
|
||||
self.assertIs(reduced_games_package1, reduced_games_package2)
|
||||
self.assertEqual(location_name_groups1, location_name_groups2)
|
||||
self.assertEqual(item_name_groups1, item_name_groups2)
|
||||
|
||||
def test_get_from_db(self) -> None:
|
||||
"""Tests that a get with only checksum will load the full data from db and is cached"""
|
||||
game_name = "Another Game"
|
||||
full_games_package = deepcopy(self.static_data[self.any_game])
|
||||
full_games_package["checksum"] = "3456"
|
||||
cks: GamesPackage = {"checksum": full_games_package["checksum"]} # noqa: pycharm doesn't like this
|
||||
FakeGameDataPackage.add(full_games_package["checksum"], full_games_package)
|
||||
before_add = len(self.cache._reduced_games_packages)
|
||||
data = self.cache.get(game_name, cks)
|
||||
self.assert_conversion(full_games_package, *data) # type: ignore
|
||||
self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
|
||||
|
||||
def test_get_missing_from_db_uses_full_games_package(self) -> None:
|
||||
"""Tests that a get with full data (missing from db) will use the full data and is cached"""
|
||||
game_name = "Yet Another Game"
|
||||
full_games_package = deepcopy(self.static_data[self.any_game])
|
||||
full_games_package["checksum"] = "4567"
|
||||
before_add = len(self.cache._reduced_games_packages)
|
||||
data = self.cache.get(game_name, full_games_package)
|
||||
self.assert_conversion(full_games_package, *data) # type: ignore
|
||||
self.assertEqual(before_add + 1, len(self.cache._reduced_games_packages))
|
||||
|
||||
def test_get_without_checksum_uses_full_games_package(self) -> None:
|
||||
"""Tests that a get with full data and no checksum will use the full data and is not cached"""
|
||||
game_name = "Yet Another Game"
|
||||
full_games_package = deepcopy(self.static_data[self.any_game])
|
||||
del full_games_package["checksum"]
|
||||
before_add = len(self.cache._reduced_games_packages)
|
||||
data = self.cache.get(game_name, full_games_package)
|
||||
self.assert_conversion(full_games_package, *data) # type: ignore
|
||||
self.assertEqual(before_add, len(self.cache._reduced_games_packages))
|
||||
|
||||
def test_get_missing_from_db_raises(self) -> None:
|
||||
"""Tests that a get that requires a row to exist raise an exception if it doesn't"""
|
||||
with self.assertRaises(Exception):
|
||||
_ = self.cache.get("Does not exist", {"checksum": "0000"})
|
||||
@@ -363,7 +363,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
if item == "settings":
|
||||
return self.__class__.settings
|
||||
return getattr(self.__class__, item)
|
||||
raise AttributeError
|
||||
|
||||
# overridable methods that get called by Main.py, sorted by execution order
|
||||
|
||||
@@ -1699,8 +1699,7 @@ def patch_rom(multiworld: MultiWorld, rom: LocalRom, player: int, enemized: bool
|
||||
|
||||
# set rom name
|
||||
# 21 bytes
|
||||
from Utils import __version__
|
||||
rom.name = bytearray(f'AP{__version__.replace(".", "")[0:3]}_{player}_{multiworld.seed:11}\0', 'utf8')[:21]
|
||||
rom.name = bytearray(f'AP{local_world.world_version.as_simple_string().replace(".", "")[0:3]}_{player}_{multiworld.seed:11}\0', 'utf8')[:21]
|
||||
rom.name.extend([0] * (21 - len(rom.name)))
|
||||
rom.write_bytes(0x7FC0, rom.name)
|
||||
|
||||
|
||||
6
worlds/alttp/archipelago.json
Normal file
6
worlds/alttp/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "A Link to the Past",
|
||||
"minimum_ap_version": "0.6.6",
|
||||
"world_version": "5.1.0",
|
||||
"authors": ["Berserker"]
|
||||
}
|
||||
@@ -2025,13 +2025,13 @@ location_tables: Dict[str, List[DS3LocationData]] = {
|
||||
DS3LocationData("LC: Rusted Coin - chapel", "Rusted Coin x2"),
|
||||
DS3LocationData("LC: Braille Divine Tome of Lothric - wyvern room",
|
||||
"Braille Divine Tome of Lothric", hidden=True), # Hidden fall
|
||||
DS3LocationData("LC: Red Tearstone Ring - chapel, drop onto roof", "Red Tearstone Ring"),
|
||||
DS3LocationData("LC: Red Tearstone Ring - chapel, balcony before drop", "Red Tearstone Ring"),
|
||||
DS3LocationData("LC: Twinkling Titanite - moat, left side", "Twinkling Titanite x2"),
|
||||
DS3LocationData("LC: Large Soul of a Nameless Soldier - plaza left, by pillar",
|
||||
"Large Soul of a Nameless Soldier"),
|
||||
DS3LocationData("LC: Titanite Scale - altar", "Titanite Scale x3"),
|
||||
DS3LocationData("LC: Titanite Scale - chapel, chest", "Titanite Scale"),
|
||||
DS3LocationData("LC: Hood of Prayer", "Hood of Prayer"),
|
||||
DS3LocationData("LC: Hood of Prayer - ascent, chest at beginning", "Hood of Prayer"),
|
||||
DS3LocationData("LC: Robe of Prayer - ascent, chest at beginning", "Robe of Prayer"),
|
||||
DS3LocationData("LC: Skirt of Prayer - ascent, chest at beginning", "Skirt of Prayer"),
|
||||
DS3LocationData("LC: Spirit Tree Crest Shield - basement, chest",
|
||||
|
||||
@@ -6,6 +6,7 @@ from logging import warning
|
||||
from typing import cast, Any, Callable, Dict, Set, List, Optional, TextIO, Union
|
||||
|
||||
from BaseClasses import CollectionState, MultiWorld, Region, Location, LocationProgressType, Entrance, Tutorial, ItemClassification
|
||||
from Fill import remaining_fill
|
||||
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from worlds.generic.Rules import CollectionRule, ItemRule, add_rule, add_item_rule
|
||||
@@ -1473,6 +1474,7 @@ class DarkSouls3World(World):
|
||||
f"contain smoothed items, but only {len(converted_item_order)} items to smooth."
|
||||
)
|
||||
|
||||
sorted_spheres = []
|
||||
for sphere in locations_by_sphere:
|
||||
locations = [loc for loc in sphere if loc.item.name in names]
|
||||
|
||||
@@ -1480,12 +1482,12 @@ class DarkSouls3World(World):
|
||||
offworld = ds3_world._shuffle([loc for loc in locations if loc.game != "Dark Souls III"])
|
||||
onworld = sorted((loc for loc in locations if loc.game == "Dark Souls III"),
|
||||
key=lambda loc: loc.data.region_value)
|
||||
|
||||
# Give offworld regions the last (best) items within a given sphere
|
||||
for location in onworld + offworld:
|
||||
new_item = ds3_world._pop_item(location, converted_item_order)
|
||||
location.item = new_item
|
||||
new_item.location = location
|
||||
sorted_spheres.extend(onworld)
|
||||
sorted_spheres.extend(offworld)
|
||||
|
||||
converted_item_order.reverse()
|
||||
remaining_fill(multiworld, sorted_spheres, converted_item_order, name="DS3 Smoothing", check_location_can_fill=True)
|
||||
|
||||
if ds3_world.options.smooth_upgrade_items:
|
||||
base_names = {
|
||||
@@ -1518,19 +1520,6 @@ class DarkSouls3World(World):
|
||||
self.random.shuffle(copy)
|
||||
return copy
|
||||
|
||||
def _pop_item(
|
||||
self,
|
||||
location: Location,
|
||||
items: List[DarkSouls3Item]
|
||||
) -> DarkSouls3Item:
|
||||
"""Returns the next item in items that can be assigned to location."""
|
||||
for i, item in enumerate(items):
|
||||
if location.can_fill(self.multiworld.state, item, False):
|
||||
return items.pop(i)
|
||||
|
||||
# If we can't find a suitable item, give up and assign an unsuitable one.
|
||||
return items.pop(0)
|
||||
|
||||
def _get_our_locations(self) -> List[DarkSouls3Location]:
|
||||
return cast(List[DarkSouls3Location], self.multiworld.get_locations(self.player))
|
||||
|
||||
|
||||
5
worlds/ff1/archipelago.json
Normal file
5
worlds/ff1/archipelago.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"game": "Final Fantasy",
|
||||
"world_version": "1.0.0",
|
||||
"authors": ["Rosalie"]
|
||||
}
|
||||
@@ -216,6 +216,28 @@ dungeon major item chests. Because the from_pool value is `false`, a copy of the
|
||||
while the originals remain in the item pool to be shuffled. The second block will place the Kokiri Sword in the Deku
|
||||
Tree Slingshot Chest, again not from the pool.
|
||||
|
||||
```yaml
|
||||
plando_items:
|
||||
# Example block - Hollow Knight
|
||||
- items:
|
||||
Claw : true
|
||||
world:
|
||||
- BobsWitness
|
||||
- BobsRogueLegacy
|
||||
```
|
||||
This block will attempt to place all items in the Claw item group into any locations within the game slots named
|
||||
"BobsWitness" and "BobsRogueLegacy."
|
||||
|
||||
**NOTE:** As item groups may contain items that are not currently present in the item pool, use of `true` with
|
||||
item groups, as shown here, is strongly recommended to avoid creation of unintended items.
|
||||
|
||||
For example, the Claw item group for Hollow Knight includes Mantis_Claw, Left_Mantis_Claw, and Right_Mantis_Claw.
|
||||
Depending on a different yaml setting, the Generator will create either one Mantis_Claw item, or one each of the
|
||||
Left_Mantis_Claw and Right_Mantis_Claw items. By default, the Generator will create any missing item(s) in addition
|
||||
to using the intended item(s), resulting in placement of all three items from the item group: Mantis_Claw,
|
||||
Left_Mantis_Claw and Right_Mantis_Claw. Use of the true value, as shown in the example, restricts the Generator to
|
||||
using only the items from the item group that are already present in the item pool.
|
||||
|
||||
## Boss Plando
|
||||
|
||||
This is currently only supported by A Link to the Past and Kirby's Dream Land 3. Boss plando allows a player to place a
|
||||
|
||||
6
worlds/lingo/archipelago.json
Normal file
6
worlds/lingo/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Lingo",
|
||||
"authors": ["hatkirby"],
|
||||
"minimum_ap_version": "0.6.3",
|
||||
"world_version": "5.0.0"
|
||||
}
|
||||
@@ -4470,6 +4470,10 @@
|
||||
panel: SEVEN (1)
|
||||
- room: Outside The Initiated
|
||||
panel: SEVEN (2)
|
||||
First Eight:
|
||||
event: True
|
||||
panels:
|
||||
- EIGHT
|
||||
Nines:
|
||||
id:
|
||||
- Count Up Room Area Doors/Door_nine_hider
|
||||
@@ -4612,7 +4616,7 @@
|
||||
enter_only: True
|
||||
orientation: east
|
||||
required_door:
|
||||
door: Eights
|
||||
door: First Eight
|
||||
progression:
|
||||
Progressive Number Hunt:
|
||||
panel_doors:
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
from typing import Any, ClassVar, TextIO
|
||||
|
||||
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, MultiWorld, Tutorial
|
||||
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, MultiWorld, Tutorial, \
|
||||
PlandoOptions
|
||||
from Options import Accessibility
|
||||
from Utils import output_path
|
||||
from settings import FilePath, Group
|
||||
@@ -18,6 +19,7 @@ from .rules import MessengerHardRules, MessengerOOBRules, MessengerRules
|
||||
from .shop import FIGURINES, PROG_SHOP_ITEMS, SHOP_ITEMS, USEFUL_SHOP_ITEMS, shuffle_shop_prices
|
||||
from .subclasses import MessengerItem, MessengerRegion, MessengerShopLocation
|
||||
from .transitions import disconnect_entrances, shuffle_transitions
|
||||
from .universal_tracker import reverse_portal_exits_into_portal_plando, reverse_transitions_into_plando_connections
|
||||
|
||||
components.append(
|
||||
Component(
|
||||
@@ -151,6 +153,10 @@ class MessengerWorld(World):
|
||||
reachable_locs: bool = False
|
||||
filler: dict[str, int]
|
||||
|
||||
@staticmethod
|
||||
def interpret_slot_data(slot_data: dict[str, Any]) -> dict[str, Any]:
|
||||
return slot_data
|
||||
|
||||
def generate_early(self) -> None:
|
||||
if self.options.goal == Goal.option_power_seal_hunt:
|
||||
self.total_seals = self.options.total_seals.value
|
||||
@@ -188,6 +194,11 @@ class MessengerWorld(World):
|
||||
self.spoiler_portal_mapping = {}
|
||||
self.transitions = []
|
||||
|
||||
if hasattr(self.multiworld, "re_gen_passthrough"):
|
||||
slot_data = self.multiworld.re_gen_passthrough.get(self.game)
|
||||
if slot_data:
|
||||
self.starting_portals = slot_data["starting_portals"]
|
||||
|
||||
def create_regions(self) -> None:
|
||||
# MessengerRegion adds itself to the multiworld
|
||||
# create simple regions
|
||||
@@ -279,6 +290,16 @@ class MessengerWorld(World):
|
||||
def connect_entrances(self) -> None:
|
||||
if self.options.shuffle_transitions:
|
||||
disconnect_entrances(self)
|
||||
keep_entrance_logic = False
|
||||
|
||||
if hasattr(self.multiworld, "re_gen_passthrough"):
|
||||
slot_data = self.multiworld.re_gen_passthrough.get(self.game)
|
||||
if slot_data:
|
||||
self.multiworld.plando_options |= PlandoOptions.connections
|
||||
self.options.portal_plando.value = reverse_portal_exits_into_portal_plando(slot_data["portal_exits"])
|
||||
self.options.plando_connections.value = reverse_transitions_into_plando_connections(slot_data["transitions"])
|
||||
keep_entrance_logic = True
|
||||
|
||||
add_closed_portal_reqs(self)
|
||||
# i need portal shuffle to happen after rules exist so i can validate it
|
||||
attempts = 20
|
||||
@@ -295,7 +316,7 @@ class MessengerWorld(World):
|
||||
raise RuntimeError("Unable to generate valid portal output.")
|
||||
|
||||
if self.options.shuffle_transitions:
|
||||
shuffle_transitions(self)
|
||||
shuffle_transitions(self, keep_entrance_logic)
|
||||
|
||||
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
|
||||
if self.options.available_portals < 6:
|
||||
@@ -463,7 +484,7 @@ class MessengerWorld(World):
|
||||
"loc_data": {loc.address: {loc.item.name: [loc.item.code, loc.item.flags]}
|
||||
for loc in multiworld.get_filled_locations() if loc.address},
|
||||
}
|
||||
|
||||
|
||||
output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(output)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, Location, Region
|
||||
from entrance_rando import ERPlacementState
|
||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, Region
|
||||
from .regions import LOCATIONS, MEGA_SHARDS
|
||||
from .shop import FIGURINES, SHOP_ITEMS
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from BaseClasses import Entrance, Region
|
||||
from BaseClasses import Region, CollectionRule
|
||||
from entrance_rando import EntranceType, randomize_entrances
|
||||
from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS
|
||||
from .options import ShuffleTransitions, TransitionPlando
|
||||
@@ -26,7 +26,6 @@ def disconnect_entrances(world: "MessengerWorld") -> None:
|
||||
entrance.randomization_type = er_type
|
||||
mock_entrance.randomization_type = er_type
|
||||
|
||||
|
||||
for parent, child in RANDOMIZED_CONNECTIONS.items():
|
||||
if child == "Corrupted Future":
|
||||
entrance = world.get_entrance("Artificer's Portal")
|
||||
@@ -36,8 +35,9 @@ def disconnect_entrances(world: "MessengerWorld") -> None:
|
||||
entrance = world.get_entrance(f"{parent} -> {child}")
|
||||
disconnect_entrance()
|
||||
|
||||
def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando) -> None:
|
||||
def remove_dangling_exit(region: Region) -> None:
|
||||
|
||||
def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando, keep_logic: bool = False) -> None:
|
||||
def remove_dangling_exit(region: Region) -> CollectionRule:
|
||||
# find the disconnected exit and remove references to it
|
||||
for _exit in region.exits:
|
||||
if not _exit.connected_region:
|
||||
@@ -45,6 +45,7 @@ def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando
|
||||
else:
|
||||
raise ValueError(f"Unable to find randomized transition for {plando_connection}")
|
||||
region.exits.remove(_exit)
|
||||
return _exit.access_rule
|
||||
|
||||
def remove_dangling_entrance(region: Region) -> None:
|
||||
# find the disconnected entrance and remove references to it
|
||||
@@ -65,30 +66,35 @@ def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando
|
||||
else:
|
||||
dangling_exit = world.get_entrance("Artificer's Challenge")
|
||||
reg1.exits.remove(dangling_exit)
|
||||
access_rule = dangling_exit.access_rule
|
||||
else:
|
||||
reg1 = world.get_region(plando_connection.entrance)
|
||||
remove_dangling_exit(reg1)
|
||||
|
||||
access_rule = remove_dangling_exit(reg1)
|
||||
|
||||
reg2 = world.get_region(plando_connection.exit)
|
||||
remove_dangling_entrance(reg2)
|
||||
# connect the regions
|
||||
reg1.connect(reg2)
|
||||
new_exit1 = reg1.connect(reg2)
|
||||
if keep_logic:
|
||||
new_exit1.access_rule = access_rule
|
||||
|
||||
# pretend the user set the plando direction as "both" regardless of what they actually put on coupled
|
||||
if ((world.options.shuffle_transitions == ShuffleTransitions.option_coupled
|
||||
or plando_connection.direction == "both")
|
||||
and plando_connection.exit in RANDOMIZED_CONNECTIONS):
|
||||
remove_dangling_exit(reg2)
|
||||
access_rule = remove_dangling_exit(reg2)
|
||||
remove_dangling_entrance(reg1)
|
||||
reg2.connect(reg1)
|
||||
new_exit2 = reg2.connect(reg1)
|
||||
if keep_logic:
|
||||
new_exit2.access_rule = access_rule
|
||||
|
||||
|
||||
def shuffle_transitions(world: "MessengerWorld") -> None:
|
||||
def shuffle_transitions(world: "MessengerWorld", keep_logic: bool = False) -> None:
|
||||
coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled
|
||||
|
||||
plando = world.options.plando_connections
|
||||
if plando:
|
||||
connect_plando(world, plando)
|
||||
connect_plando(world, plando, keep_logic)
|
||||
|
||||
result = randomize_entrances(world, coupled, {0: [0]})
|
||||
|
||||
|
||||
41
worlds/messenger/universal_tracker.py
Normal file
41
worlds/messenger/universal_tracker.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from Options import PlandoConnection
|
||||
from .connections import RANDOMIZED_CONNECTIONS
|
||||
from .portals import REGION_ORDER, SHOP_POINTS, CHECKPOINTS
|
||||
from .transitions import TRANSITIONS
|
||||
|
||||
REVERSED_RANDOMIZED_CONNECTIONS = {v: k for k, v in RANDOMIZED_CONNECTIONS.items()}
|
||||
|
||||
|
||||
def find_spot(portal_key: int) -> str:
|
||||
"""finds the spot associated with the portal key"""
|
||||
parent = REGION_ORDER[portal_key // 100]
|
||||
if portal_key % 100 == 0:
|
||||
return f"{parent} Portal"
|
||||
if portal_key % 100 // 10 == 1:
|
||||
return SHOP_POINTS[parent][portal_key % 10]
|
||||
return CHECKPOINTS[parent][portal_key % 10]
|
||||
|
||||
|
||||
def reverse_portal_exits_into_portal_plando(portal_exits: list[int]) -> list[PlandoConnection]:
|
||||
return [
|
||||
PlandoConnection("Autumn Hills", find_spot(portal_exits[0]), "both"),
|
||||
PlandoConnection("Riviere Turquoise", find_spot(portal_exits[1]), "both"),
|
||||
PlandoConnection("Howling Grotto", find_spot(portal_exits[2]), "both"),
|
||||
PlandoConnection("Sunken Shrine", find_spot(portal_exits[3]), "both"),
|
||||
PlandoConnection("Searing Crags", find_spot(portal_exits[4]), "both"),
|
||||
PlandoConnection("Glacial Peak", find_spot(portal_exits[5]), "both"),
|
||||
]
|
||||
|
||||
|
||||
def reverse_transitions_into_plando_connections(transitions: list[list[int]]) -> list[PlandoConnection]:
|
||||
plando_connections = []
|
||||
|
||||
for connection in [
|
||||
PlandoConnection(REVERSED_RANDOMIZED_CONNECTIONS[TRANSITIONS[transition[0]]], TRANSITIONS[transition[1]], "both")
|
||||
for transition in transitions
|
||||
]:
|
||||
if connection.exit in {con.entrance for con in plando_connections}:
|
||||
continue
|
||||
plando_connections.append(connection)
|
||||
|
||||
return plando_connections
|
||||
@@ -1,11 +1,11 @@
|
||||
from typing import TYPE_CHECKING, Optional, Set, List, Dict
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
from typing import TYPE_CHECKING, Optional, Set, List, Dict
|
||||
from NetUtils import ClientStatus
|
||||
from .Locations import roomCount, nonBlock, beanstones, roomException, shop, badge, pants, eReward
|
||||
from .Items import items_by_id
|
||||
|
||||
import asyncio
|
||||
|
||||
import worlds._bizhawk as bizhawk
|
||||
from worlds._bizhawk.client import BizHawkClient
|
||||
@@ -41,8 +41,6 @@ class MLSSClient(BizHawkClient):
|
||||
self.local_events = []
|
||||
|
||||
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
|
||||
from CommonClient import logger
|
||||
|
||||
try:
|
||||
# Check ROM name/patch version
|
||||
rom_name_bytes = await bizhawk.read(ctx.bizhawk_ctx, [(0xA0, 14, "ROM")])
|
||||
@@ -72,20 +70,15 @@ class MLSSClient(BizHawkClient):
|
||||
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
|
||||
ctx.auth = self.player_name
|
||||
|
||||
def on_package(self, ctx, cmd, args) -> None:
|
||||
if cmd == "RoomInfo":
|
||||
ctx.seed_name = args["seed_name"]
|
||||
|
||||
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
|
||||
from CommonClient import logger
|
||||
|
||||
try:
|
||||
if ctx.seed_name is None:
|
||||
if ctx.server_seed_name is None:
|
||||
return
|
||||
if not self.seed_verify:
|
||||
seed = await bizhawk.read(ctx.bizhawk_ctx, [(0xDF00A0, len(ctx.seed_name), "ROM")])
|
||||
seed = await bizhawk.read(ctx.bizhawk_ctx, [(0xDF00A0, len(ctx.server_seed_name), "ROM")])
|
||||
seed = seed[0].decode("UTF-8")
|
||||
if seed not in ctx.seed_name:
|
||||
if seed not in ctx.server_seed_name:
|
||||
logger.info(
|
||||
"ERROR: The ROM you loaded is for a different game of AP. "
|
||||
"Please make sure the host has sent you the correct patch file, "
|
||||
|
||||
@@ -140,8 +140,8 @@ def cmd_pool(self: "BizHawkClientCommandProcessor") -> None:
|
||||
|
||||
|
||||
def cmd_request(self: "BizHawkClientCommandProcessor", amount: str, target: str) -> None:
|
||||
from worlds._bizhawk.context import BizHawkClientContext
|
||||
"""Request a refill from EnergyLink."""
|
||||
from worlds._bizhawk.context import BizHawkClientContext
|
||||
if self.ctx.game != "Mega Man 2":
|
||||
logger.warning("This command can only be used when playing Mega Man 2.")
|
||||
return
|
||||
|
||||
1
worlds/mm3/.apignore
Normal file
1
worlds/mm3/.apignore
Normal file
@@ -0,0 +1 @@
|
||||
/src/*
|
||||
275
worlds/mm3/__init__.py
Normal file
275
worlds/mm3/__init__.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import Any, Sequence, ClassVar
|
||||
|
||||
from BaseClasses import Tutorial, ItemClassification, MultiWorld, Item, Location
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from .names import (gamma, gemini_man_stage, needle_man_stage, hard_man_stage, magnet_man_stage, top_man_stage,
|
||||
snake_man_stage, spark_man_stage, shadow_man_stage, rush_marine, rush_jet, rush_coil)
|
||||
from .items import (item_table, item_names, MM3Item, filler_item_weights, robot_master_weapon_table,
|
||||
stage_access_table, rush_item_table, lookup_item_to_id)
|
||||
from .locations import (MM3Location, mm3_regions, MM3Region, lookup_location_to_id,
|
||||
location_groups)
|
||||
from .rom import patch_rom, MM3ProcedurePatch, MM3LCHASH, MM3VCHASH, PROTEUSHASH, MM3NESHASH
|
||||
from .options import MM3Options, Consumables
|
||||
from .client import MegaMan3Client
|
||||
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 3")
|
||||
|
||||
|
||||
class MM3Settings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the MM3 EN rom"""
|
||||
description = "Mega Man 3 ROM File"
|
||||
copy_to: str | None = "Mega Man 3 (USA).nes"
|
||||
md5s = [MM3NESHASH, MM3LCHASH, PROTEUSHASH, MM3VCHASH]
|
||||
|
||||
def browse(self: settings.T,
|
||||
filetypes: Sequence[tuple[str, Sequence[str]]] | None = None,
|
||||
**kwargs: Any) -> settings.T | None:
|
||||
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 MM3WebWorld(WebWorld):
|
||||
theme = "partyTime"
|
||||
tutorials = [
|
||||
|
||||
Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"A guide to setting up the Mega Man 3 randomizer connected to an Archipelago Multiworld.",
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["Silvris"]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class MM3World(World):
|
||||
"""
|
||||
Following his second defeat by Mega Man, Dr. Wily has finally come to his senses. He and Dr. Light begin work on
|
||||
Gamma, a giant peacekeeping robot. However, Gamma's power source, the Energy Elements, are being guarded by the
|
||||
Robot Masters sent to retrieve them. It's up to Mega Man to retrieve the Energy Elements and defeat the mastermind
|
||||
behind the Robot Masters' betrayal.
|
||||
"""
|
||||
|
||||
game = "Mega Man 3"
|
||||
settings: ClassVar[MM3Settings]
|
||||
options_dataclass = MM3Options
|
||||
options: MM3Options
|
||||
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 = MM3WebWorld()
|
||||
rom_name: bytearray
|
||||
|
||||
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_4_weapons: dict[int, list[int]] = {}
|
||||
|
||||
def create_regions(self) -> None:
|
||||
menu = MM3Region("Menu", self.player, self.multiworld)
|
||||
self.multiworld.regions.append(menu)
|
||||
location: MM3Location
|
||||
for name, region in mm3_regions.items():
|
||||
stage = MM3Region(name, self.player, self.multiworld)
|
||||
if not region.parent:
|
||||
menu.connect(stage, f"To {name}",
|
||||
lambda state, req=tuple(region.required_items): state.has_all(req, self.player))
|
||||
else:
|
||||
old_stage = self.get_region(region.parent)
|
||||
old_stage.connect(stage, f"To {name}",
|
||||
lambda state, req=tuple(region.required_items): state.has_all(req, self.player))
|
||||
stage.add_locations({loc: data.location_id for loc, data in region.locations.items()
|
||||
if (not data.energy or self.options.consumables.value in (Consumables.option_weapon_health, Consumables.option_all))
|
||||
and (not data.oneup_tank or self.options.consumables.value in (Consumables.option_1up_etank, Consumables.option_all))})
|
||||
for location in stage.get_locations():
|
||||
if location.address is None and location.name != gamma:
|
||||
location.place_locked_item(MM3Item(location.name, ItemClassification.progression,
|
||||
None, self.player))
|
||||
self.multiworld.regions.append(stage)
|
||||
goal_location = self.get_location(gamma)
|
||||
goal_location.place_locked_item(MM3Item("Victory", ItemClassification.progression, None, self.player))
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
|
||||
|
||||
def create_item(self, name: str, force_non_progression: bool = False) -> MM3Item:
|
||||
item = item_table[name]
|
||||
classification = ItemClassification.filler
|
||||
if item.progression and not force_non_progression:
|
||||
classification = ItemClassification.progression_skip_balancing \
|
||||
if item.skip_balancing else ItemClassification.progression
|
||||
if item.useful:
|
||||
classification |= ItemClassification.useful
|
||||
return MM3Item(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[0x0101 + 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 rush_item_table.keys()])
|
||||
total_checks = 31
|
||||
if self.options.consumables in (Consumables.option_1up_etank,
|
||||
Consumables.option_all):
|
||||
total_checks += 33
|
||||
if self.options.consumables in (Consumables.option_weapon_health,
|
||||
Consumables.option_all):
|
||||
total_checks += 106
|
||||
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 (self.options.starting_robot_master.current_key == "gemini_man"
|
||||
and not any(item in self.options.start_inventory for item in rush_item_table.keys())) or \
|
||||
(self.options.starting_robot_master.current_key == "hard_man"
|
||||
and not any(item in self.options.start_inventory for item in [rush_coil, rush_jet])):
|
||||
robot_master_pool = [0, 1, 4, 5, 6, 7, ]
|
||||
if rush_marine in self.options.start_inventory:
|
||||
robot_master_pool.append(2)
|
||||
self.options.starting_robot_master.value = self.random.choice(robot_master_pool)
|
||||
logger.warning(
|
||||
f"Incompatible starting Robot Master, changing to "
|
||||
f"{self.options.starting_robot_master.current_key.replace('_', ' ').title()}")
|
||||
|
||||
def fill_hook(self,
|
||||
prog_item_pool: list["Item"],
|
||||
useful_item_pool: list["Item"],
|
||||
filler_item_pool: 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
|
||||
# MM3 is worse than MM2 here, some of the RBMs can also require Rush
|
||||
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: needle_man_stage,
|
||||
1: magnet_man_stage,
|
||||
2: gemini_man_stage,
|
||||
3: hard_man_stage,
|
||||
4: top_man_stage,
|
||||
5: snake_man_stage,
|
||||
6: spark_man_stage,
|
||||
7: shadow_man_stage
|
||||
}
|
||||
affected_rbm = [2, 3] # Gemini and Hard will always have this happen
|
||||
possible_rbm = [0, 7] # Needle and Shadow are always valid targets, due to Rush Marine/Jet receive
|
||||
if self.options.consumables:
|
||||
possible_rbm.extend([4, 5]) # every stage has at least one of each consumable
|
||||
if self.options.consumables in (Consumables.option_weapon_health, Consumables.option_all):
|
||||
possible_rbm.extend([1, 6])
|
||||
else:
|
||||
affected_rbm.extend([1, 6])
|
||||
else:
|
||||
affected_rbm.extend([1, 4, 5, 6]) # only two checks on non consumables
|
||||
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 prog_item_pool
|
||||
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)
|
||||
prog_item_pool.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 prog_item_pool
|
||||
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 == 0x0101 + self.options.starting_robot_master.value)
|
||||
weapon_location = self.get_location(weapon_name)
|
||||
weapon_location.place_locked_item(placed_weapon)
|
||||
prog_item_pool.remove(placed_weapon)
|
||||
fill_locations.remove(weapon_location)
|
||||
|
||||
def generate_output(self, output_directory: str) -> None:
|
||||
try:
|
||||
patch = MM3ProcedurePatch(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_4_weapons": self.wily_4_weapons
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def interpret_slot_data(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_4_weapons"].items()}
|
||||
return {"weapon_damage": local_weapon, "wily_4_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]
|
||||
6
worlds/mm3/archipelago.json
Normal file
6
worlds/mm3/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Mega Man 3",
|
||||
"authors": ["Silvris"],
|
||||
"world_version": "0.1.7",
|
||||
"minimum_ap_version": "0.6.4"
|
||||
}
|
||||
783
worlds/mm3/client.py
Normal file
783
worlds/mm3/client.py
Normal file
@@ -0,0 +1,783 @@
|
||||
import logging
|
||||
import time
|
||||
from enum import IntEnum
|
||||
from base64 import b64encode
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from NetUtils import ClientStatus, color, NetworkItem
|
||||
from worlds._bizhawk.client import BizHawkClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from worlds._bizhawk.context import BizHawkClientContext, BizHawkClientCommandProcessor
|
||||
|
||||
nes_logger = logging.getLogger("NES")
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
MM3_CURRENT_STAGE = 0x22
|
||||
MM3_MEGAMAN_STATE = 0x30
|
||||
MM3_PROG_STATE = 0x60
|
||||
MM3_ROBOT_MASTERS_DEFEATED = 0x61
|
||||
MM3_DOC_STATUS = 0x62
|
||||
MM3_HEALTH = 0xA2
|
||||
MM3_WEAPON_ENERGY = 0xA3
|
||||
MM3_WEAPONS = {
|
||||
1: 1,
|
||||
2: 3,
|
||||
3: 0,
|
||||
4: 2,
|
||||
5: 4,
|
||||
6: 5,
|
||||
7: 7,
|
||||
8: 9,
|
||||
0x11: 6,
|
||||
0x12: 8,
|
||||
0x13: 10,
|
||||
}
|
||||
|
||||
MM3_DOC_REMAP = {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 3,
|
||||
4: 6,
|
||||
5: 7,
|
||||
6: 4,
|
||||
7: 5
|
||||
}
|
||||
MM3_LIVES = 0xAE
|
||||
MM3_E_TANKS = 0xAF
|
||||
MM3_ENERGY_BAR = 0xB2
|
||||
MM3_CONSUMABLES = 0x150
|
||||
MM3_ROBOT_MASTERS_UNLOCKED = 0x680
|
||||
MM3_DOC_ROBOT_UNLOCKED = 0x681
|
||||
MM3_ENERGYLINK = 0x682
|
||||
MM3_LAST_WILY = 0x683
|
||||
MM3_RBM_STROBE = 0x684
|
||||
MM3_SFX_QUEUE = 0x685
|
||||
MM3_DOC_ROBOT_DEFEATED = 0x686
|
||||
MM3_COMPLETED_STAGES = 0x687
|
||||
MM3_RECEIVED_ITEMS = 0x688
|
||||
MM3_RUSH_RECEIVED = 0x689
|
||||
|
||||
MM3_CONSUMABLE_TABLE: dict[int, dict[int, tuple[int, int]]] = {
|
||||
# Stage:
|
||||
# Item: (byte offset, bit mask)
|
||||
0: {
|
||||
0x0200: (0, 5),
|
||||
0x0201: (3, 2),
|
||||
},
|
||||
1: {
|
||||
0x0202: (2, 6),
|
||||
0x0203: (2, 5),
|
||||
0x0204: (2, 4),
|
||||
0x0205: (2, 3),
|
||||
0x0206: (3, 6),
|
||||
0x0207: (3, 5),
|
||||
0x0208: (3, 7),
|
||||
0x0209: (4, 0)
|
||||
},
|
||||
2: {
|
||||
0x020A: (2, 7),
|
||||
0x020B: (3, 0),
|
||||
0x020C: (3, 1),
|
||||
0x020D: (3, 2),
|
||||
0x020E: (4, 2),
|
||||
0x020F: (4, 3),
|
||||
0x0210: (4, 7),
|
||||
0x0211: (5, 1),
|
||||
0x0212: (6, 1),
|
||||
0x0213: (7, 0)
|
||||
},
|
||||
3: {
|
||||
0x0214: (0, 6),
|
||||
0x0215: (1, 5),
|
||||
0x0216: (2, 3),
|
||||
0x0217: (2, 7),
|
||||
0x0218: (2, 6),
|
||||
0x0219: (2, 5),
|
||||
0x021A: (4, 5),
|
||||
},
|
||||
4: {
|
||||
0x021B: (1, 3),
|
||||
0x021C: (1, 5),
|
||||
0x021D: (1, 7),
|
||||
0x021E: (2, 0),
|
||||
0x021F: (1, 6),
|
||||
0x0220: (2, 4),
|
||||
0x0221: (2, 5),
|
||||
0x0222: (4, 5)
|
||||
},
|
||||
5: {
|
||||
0x0223: (3, 0),
|
||||
0x0224: (3, 2),
|
||||
0x0225: (4, 5),
|
||||
0x0226: (4, 6),
|
||||
0x0227: (6, 4),
|
||||
},
|
||||
6: {
|
||||
0x0228: (2, 0),
|
||||
0x0229: (2, 1),
|
||||
0x022A: (3, 1),
|
||||
0x022B: (3, 2),
|
||||
0x022C: (3, 3),
|
||||
0x022D: (3, 4),
|
||||
},
|
||||
7: {
|
||||
0x022E: (3, 5),
|
||||
0x022F: (3, 4),
|
||||
0x0230: (3, 3),
|
||||
0x0231: (3, 2),
|
||||
},
|
||||
8: {
|
||||
0x0232: (1, 4),
|
||||
0x0233: (2, 1),
|
||||
0x0234: (2, 2),
|
||||
0x0235: (2, 5),
|
||||
0x0236: (3, 5),
|
||||
0x0237: (4, 2),
|
||||
0x0238: (4, 4),
|
||||
0x0239: (5, 3),
|
||||
0x023A: (6, 0),
|
||||
0x023B: (6, 1),
|
||||
0x023C: (7, 5),
|
||||
|
||||
},
|
||||
9: {
|
||||
0x023D: (3, 2),
|
||||
0x023E: (3, 6),
|
||||
0x023F: (4, 5),
|
||||
0x0240: (5, 4),
|
||||
},
|
||||
10: {
|
||||
0x0241: (0, 2),
|
||||
0x0242: (2, 4)
|
||||
},
|
||||
11: {
|
||||
0x0243: (4, 1),
|
||||
0x0244: (6, 0),
|
||||
0x0245: (6, 1),
|
||||
0x0246: (6, 2),
|
||||
0x0247: (6, 3),
|
||||
},
|
||||
12: {
|
||||
0x0248: (0, 0),
|
||||
0x0249: (0, 3),
|
||||
0x024A: (0, 5),
|
||||
0x024B: (1, 6),
|
||||
0x024C: (2, 7),
|
||||
0x024D: (2, 3),
|
||||
0x024E: (2, 1),
|
||||
0x024F: (2, 2),
|
||||
0x0250: (3, 5),
|
||||
0x0251: (3, 4),
|
||||
0x0252: (3, 6),
|
||||
0x0253: (3, 7)
|
||||
},
|
||||
13: {
|
||||
0x0254: (0, 3),
|
||||
0x0255: (0, 6),
|
||||
0x0256: (1, 0),
|
||||
0x0257: (3, 0),
|
||||
0x0258: (3, 2),
|
||||
0x0259: (3, 3),
|
||||
0x025A: (3, 4),
|
||||
0x025B: (3, 5),
|
||||
0x025C: (3, 6),
|
||||
0x025D: (4, 0),
|
||||
0x025E: (3, 7),
|
||||
0x025F: (4, 1),
|
||||
0x0260: (4, 2),
|
||||
},
|
||||
14: {
|
||||
0x0261: (0, 3),
|
||||
0x0262: (0, 2),
|
||||
0x0263: (0, 6),
|
||||
0x0264: (1, 2),
|
||||
0x0265: (1, 7),
|
||||
0x0266: (2, 0),
|
||||
0x0267: (2, 1),
|
||||
0x0268: (2, 2),
|
||||
0x0269: (2, 3),
|
||||
0x026A: (5, 2),
|
||||
0x026B: (5, 3),
|
||||
},
|
||||
15: {
|
||||
0x026C: (0, 0),
|
||||
0x026D: (0, 1),
|
||||
0x026E: (0, 2),
|
||||
0x026F: (0, 3),
|
||||
0x0270: (0, 4),
|
||||
0x0271: (0, 6),
|
||||
0x0272: (1, 0),
|
||||
0x0273: (1, 2),
|
||||
0x0274: (1, 3),
|
||||
0x0275: (1, 1),
|
||||
0x0276: (0, 7),
|
||||
0x0277: (3, 2),
|
||||
0x0278: (2, 2),
|
||||
0x0279: (2, 3),
|
||||
0x027A: (2, 4),
|
||||
0x027B: (2, 5),
|
||||
0x027C: (3, 1),
|
||||
0x027D: (3, 0),
|
||||
0x027E: (2, 7),
|
||||
0x027F: (2, 6),
|
||||
},
|
||||
16: {
|
||||
0x0280: (0, 0),
|
||||
0x0281: (0, 3),
|
||||
0x0282: (0, 1),
|
||||
0x0283: (0, 2),
|
||||
},
|
||||
17: {
|
||||
0x0284: (0, 2),
|
||||
0x0285: (0, 6),
|
||||
0x0286: (0, 1),
|
||||
0x0287: (0, 5),
|
||||
0x0288: (0, 3),
|
||||
0x0289: (0, 0),
|
||||
0x028A: (0, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def to_oneup_format(val: int) -> int:
|
||||
return ((val // 10) * 0x10) + val % 10
|
||||
|
||||
|
||||
def from_oneup_format(val: int) -> int:
|
||||
return ((val // 0x10) * 10) + val % 0x10
|
||||
|
||||
|
||||
class MM3EnergyLinkType(IntEnum):
|
||||
Life = 0
|
||||
NeedleCannon = 1
|
||||
MagnetMissile = 2
|
||||
GeminiLaser = 3
|
||||
HardKnuckle = 4
|
||||
TopSpin = 5
|
||||
SearchSnake = 6
|
||||
SparkShot = 7
|
||||
ShadowBlade = 8
|
||||
OneUP = 12
|
||||
RushCoil = 0x11
|
||||
RushMarine = 0x12
|
||||
RushJet = 0x13
|
||||
|
||||
|
||||
request_to_name: dict[str, str] = {
|
||||
"HP": "health",
|
||||
"NE": "Needle Cannon energy",
|
||||
"MA": "Magnet Missile energy",
|
||||
"GE": "Gemini Laser energy",
|
||||
"HA": "Hard Knuckle energy",
|
||||
"TO": "Top Spin energy",
|
||||
"SN": "Search Snake energy",
|
||||
"SP": "Spark Shot energy",
|
||||
"SH": "Shadow Blade energy",
|
||||
"RC": "Rush Coil energy",
|
||||
"RM": "Rush Marine energy",
|
||||
"RJ": "Rush Jet energy",
|
||||
"1U": "lives"
|
||||
}
|
||||
|
||||
HP_EXCHANGE_RATE = 500000000
|
||||
WEAPON_EXCHANGE_RATE = 250000000
|
||||
ONEUP_EXCHANGE_RATE = 14000000000
|
||||
|
||||
|
||||
def cmd_pool(self: "BizHawkClientCommandProcessor") -> None:
|
||||
"""Check the current pool of EnergyLink, and requestable refills from it."""
|
||||
if self.ctx.game != "Mega Man 3":
|
||||
logger.warning("This command can only be used when playing Mega Man 3.")
|
||||
return
|
||||
if not self.ctx.server or not self.ctx.slot:
|
||||
logger.warning("You must be connected to a server to use this command.")
|
||||
return
|
||||
energylink = self.ctx.stored_data.get(f"EnergyLink{self.ctx.team}", 0)
|
||||
health_points = energylink // HP_EXCHANGE_RATE
|
||||
weapon_points = energylink // WEAPON_EXCHANGE_RATE
|
||||
lives = energylink // ONEUP_EXCHANGE_RATE
|
||||
logger.info(f"Healing available: {health_points}\n"
|
||||
f"Weapon refill available: {weapon_points}\n"
|
||||
f"Lives available: {lives}")
|
||||
|
||||
|
||||
def cmd_request(self: "BizHawkClientCommandProcessor", amount: str, target: str) -> None:
|
||||
"""Request a refill from EnergyLink."""
|
||||
from worlds._bizhawk.context import BizHawkClientContext
|
||||
if self.ctx.game != "Mega Man 3":
|
||||
logger.warning("This command can only be used when playing Mega Man 3.")
|
||||
return
|
||||
if not self.ctx.server or not self.ctx.slot:
|
||||
logger.warning("You must be connected to a server to use this command.")
|
||||
return
|
||||
valid_targets: dict[str, MM3EnergyLinkType] = {
|
||||
"HP": MM3EnergyLinkType.Life,
|
||||
"NE": MM3EnergyLinkType.NeedleCannon,
|
||||
"MA": MM3EnergyLinkType.MagnetMissile,
|
||||
"GE": MM3EnergyLinkType.GeminiLaser,
|
||||
"HA": MM3EnergyLinkType.HardKnuckle,
|
||||
"TO": MM3EnergyLinkType.TopSpin,
|
||||
"SN": MM3EnergyLinkType.SearchSnake,
|
||||
"SP": MM3EnergyLinkType.SparkShot,
|
||||
"SH": MM3EnergyLinkType.ShadowBlade,
|
||||
"RC": MM3EnergyLinkType.RushCoil,
|
||||
"RM": MM3EnergyLinkType.RushMarine,
|
||||
"RJ": MM3EnergyLinkType.RushJet,
|
||||
"1U": MM3EnergyLinkType.OneUP
|
||||
}
|
||||
if target.upper() not in valid_targets:
|
||||
logger.warning(f"Unrecognized target {target.upper()}. Available targets: {', '.join(valid_targets.keys())}")
|
||||
return
|
||||
ctx = self.ctx
|
||||
assert isinstance(ctx, BizHawkClientContext)
|
||||
client = ctx.client_handler
|
||||
assert isinstance(client, MegaMan3Client)
|
||||
client.refill_queue.append((valid_targets[target.upper()], int(amount)))
|
||||
logger.info(f"Restoring {amount} {request_to_name[target.upper()]}.")
|
||||
|
||||
|
||||
def cmd_autoheal(self: "BizHawkClientCommandProcessor") -> None:
|
||||
"""Enable auto heal from EnergyLink."""
|
||||
if self.ctx.game != "Mega Man 3":
|
||||
logger.warning("This command can only be used when playing Mega Man 3.")
|
||||
return
|
||||
if not self.ctx.server or not self.ctx.slot:
|
||||
logger.warning("You must be connected to a server to use this command.")
|
||||
return
|
||||
else:
|
||||
assert isinstance(self.ctx.client_handler, MegaMan3Client)
|
||||
if self.ctx.client_handler.auto_heal:
|
||||
self.ctx.client_handler.auto_heal = False
|
||||
logger.info(f"Auto healing disabled.")
|
||||
else:
|
||||
self.ctx.client_handler.auto_heal = True
|
||||
logger.info(f"Auto healing enabled.")
|
||||
|
||||
|
||||
def get_sfx_writes(sfx: int) -> tuple[int, bytes, str]:
|
||||
return MM3_SFX_QUEUE, sfx.to_bytes(1, 'little'), "RAM"
|
||||
|
||||
|
||||
class MegaMan3Client(BizHawkClient):
|
||||
game = "Mega Man 3"
|
||||
system = "NES"
|
||||
patch_suffix = ".apmm3"
|
||||
item_queue: list[NetworkItem] = []
|
||||
pending_death_link: bool = False
|
||||
# default to true, as we don't want to send a deathlink until Mega Man's HP is initialized once
|
||||
sending_death_link: bool = True
|
||||
death_link: bool = False
|
||||
energy_link: bool = False
|
||||
rom: bytes | None = None
|
||||
weapon_energy: int = 0
|
||||
health_energy: int = 0
|
||||
auto_heal: bool = False
|
||||
refill_queue: list[tuple[MM3EnergyLinkType, int]] = []
|
||||
last_wily: int | None = None # default to wily 1
|
||||
doc_status: int | None = None # default to no doc progress
|
||||
|
||||
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
|
||||
from worlds._bizhawk import RequestFailedError, read, get_memory_size
|
||||
from . import MM3World
|
||||
|
||||
try:
|
||||
|
||||
if (await get_memory_size(ctx.bizhawk_ctx, "PRG ROM")) < 0x3FFB0:
|
||||
# not the entire size, but enough to check validation
|
||||
if "pool" in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands.pop("pool")
|
||||
if "request" in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands.pop("request")
|
||||
if "autoheal" in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands.pop("autoheal")
|
||||
return False
|
||||
|
||||
game_name, version = (await read(ctx.bizhawk_ctx, [(0x3F320, 21, "PRG ROM"),
|
||||
(0x3F33C, 3, "PRG ROM")]))
|
||||
if game_name[:3] != b"MM3" or version != bytes(MM3World.world_version):
|
||||
if game_name[:3] == b"MM3":
|
||||
# I think this is an easier check than the other?
|
||||
older_version = f"{version[0]}.{version[1]}.{version[2]}"
|
||||
logger.warning(f"This Mega Man 3 patch was generated for an different version of the apworld. "
|
||||
f"Please use that version to connect instead.\n"
|
||||
f"Patch version: ({older_version})\n"
|
||||
f"Client version: ({'.'.join([str(i) for i in MM3World.world_version])})")
|
||||
if "pool" in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands.pop("pool")
|
||||
if "request" in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands.pop("request")
|
||||
if "autoheal" in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands.pop("autoheal")
|
||||
return False
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
except RequestFailedError:
|
||||
return False # Should verify on the next pass
|
||||
|
||||
ctx.game = self.game
|
||||
self.rom = game_name
|
||||
ctx.items_handling = 0b111
|
||||
ctx.want_slot_data = False
|
||||
deathlink = (await read(ctx.bizhawk_ctx, [(0x3F336, 1, "PRG ROM")]))[0][0]
|
||||
if deathlink & 0x01:
|
||||
self.death_link = True
|
||||
await ctx.update_death_link(self.death_link)
|
||||
if deathlink & 0x02:
|
||||
self.energy_link = True
|
||||
|
||||
if self.energy_link:
|
||||
if "pool" not in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands["pool"] = cmd_pool
|
||||
if "request" not in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands["request"] = cmd_request
|
||||
if "autoheal" not in ctx.command_processor.commands:
|
||||
ctx.command_processor.commands["autoheal"] = cmd_autoheal
|
||||
|
||||
return True
|
||||
|
||||
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
|
||||
if self.rom:
|
||||
ctx.auth = b64encode(self.rom).decode()
|
||||
|
||||
def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: dict[str, Any]) -> None:
|
||||
if cmd == "Bounced":
|
||||
if "tags" in args:
|
||||
assert ctx.slot is not None
|
||||
if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name:
|
||||
self.on_deathlink(ctx)
|
||||
elif cmd == "Retrieved":
|
||||
if f"MM3_LAST_WILY_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||
self.last_wily = args["keys"][f"MM3_LAST_WILY_{ctx.team}_{ctx.slot}"]
|
||||
if f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}" in args["keys"]:
|
||||
self.doc_status = args["keys"][f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}"]
|
||||
elif cmd == "Connected":
|
||||
if self.energy_link:
|
||||
ctx.set_notify(f"EnergyLink{ctx.team}")
|
||||
if ctx.ui:
|
||||
ctx.ui.enable_energy_link()
|
||||
|
||||
async def send_deathlink(self, ctx: "BizHawkClientContext") -> None:
|
||||
self.sending_death_link = True
|
||||
ctx.last_death_link = time.time()
|
||||
await ctx.send_death("Mega Man was defeated.")
|
||||
|
||||
def on_deathlink(self, ctx: "BizHawkClientContext") -> None:
|
||||
ctx.last_death_link = time.time()
|
||||
self.pending_death_link = True
|
||||
|
||||
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
|
||||
from worlds._bizhawk import read, write
|
||||
|
||||
if ctx.server is None:
|
||||
return
|
||||
|
||||
if ctx.slot is None:
|
||||
return
|
||||
|
||||
# get our relevant bytes
|
||||
(prog_state, robot_masters_unlocked, robot_masters_defeated, doc_status, doc_robo_unlocked, doc_robo_defeated,
|
||||
rush_acquired, received_items, completed_stages, consumable_checks,
|
||||
e_tanks, lives, weapon_energy, health, state, bar_state, current_stage,
|
||||
energy_link_packet, last_wily) = await read(ctx.bizhawk_ctx, [
|
||||
(MM3_PROG_STATE, 1, "RAM"),
|
||||
(MM3_ROBOT_MASTERS_UNLOCKED, 1, "RAM"),
|
||||
(MM3_ROBOT_MASTERS_DEFEATED, 1, "RAM"),
|
||||
(MM3_DOC_STATUS, 1, "RAM"),
|
||||
(MM3_DOC_ROBOT_UNLOCKED, 1, "RAM"),
|
||||
(MM3_DOC_ROBOT_DEFEATED, 1, "RAM"),
|
||||
(MM3_RUSH_RECEIVED, 1, "RAM"),
|
||||
(MM3_RECEIVED_ITEMS, 1, "RAM"),
|
||||
(MM3_COMPLETED_STAGES, 0x1, "RAM"),
|
||||
(MM3_CONSUMABLES, 16, "RAM"), # Could be more but 16 definitely catches all current
|
||||
(MM3_E_TANKS, 1, "RAM"),
|
||||
(MM3_LIVES, 1, "RAM"),
|
||||
(MM3_WEAPON_ENERGY, 11, "RAM"),
|
||||
(MM3_HEALTH, 1, "RAM"),
|
||||
(MM3_MEGAMAN_STATE, 1, "RAM"),
|
||||
(MM3_ENERGY_BAR, 2, "RAM"),
|
||||
(MM3_CURRENT_STAGE, 1, "RAM"),
|
||||
(MM3_ENERGYLINK, 1, "RAM"),
|
||||
(MM3_LAST_WILY, 1, "RAM"),
|
||||
])
|
||||
|
||||
if bar_state[0] not in (0x00, 0x80):
|
||||
return # Game is not initialized
|
||||
# Bit of a trick here, bar state can only be 0x00 or 0x80 (display health bar, or don't)
|
||||
# This means it can double as init guard and in-stage tracker
|
||||
|
||||
if not ctx.finished_game and completed_stages[0] & 0x20:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL
|
||||
}])
|
||||
writes = []
|
||||
|
||||
# deathlink
|
||||
# only handle deathlink in bar state 0x80 (in stage)
|
||||
if bar_state[0] == 0x80:
|
||||
if self.pending_death_link:
|
||||
writes.append((MM3_MEGAMAN_STATE, bytes([0x0E]), "RAM"))
|
||||
self.pending_death_link = False
|
||||
self.sending_death_link = True
|
||||
if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time():
|
||||
if state[0] == 0x0E and not self.sending_death_link:
|
||||
await self.send_deathlink(ctx)
|
||||
elif state[0] != 0x0E:
|
||||
self.sending_death_link = False
|
||||
|
||||
if self.last_wily != last_wily[0]:
|
||||
if self.last_wily is None:
|
||||
# revalidate last wily from data storage
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": f"MM3_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [
|
||||
{"operation": "default", "value": 0xC}
|
||||
]}])
|
||||
await ctx.send_msgs([{"cmd": "Get", "keys": [f"MM3_LAST_WILY_{ctx.team}_{ctx.slot}"]}])
|
||||
elif last_wily[0] == 0:
|
||||
writes.append((MM3_LAST_WILY, self.last_wily.to_bytes(1, "little"), "RAM"))
|
||||
else:
|
||||
# correct our setting
|
||||
self.last_wily = last_wily[0]
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": f"MM3_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [
|
||||
{"operation": "replace", "value": self.last_wily}
|
||||
]}])
|
||||
|
||||
if self.doc_status != doc_status[0]:
|
||||
if self.doc_status is None:
|
||||
# revalidate doc status from data storage
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}", "operations": [
|
||||
{"operation": "default", "value": 0}
|
||||
]}])
|
||||
await ctx.send_msgs([{"cmd": "Get", "keys": [f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}"]}])
|
||||
elif doc_status[0] == 0:
|
||||
writes.append((MM3_DOC_STATUS, self.doc_status.to_bytes(1, "little"), "RAM"))
|
||||
else:
|
||||
# correct our setting
|
||||
# shouldn't be possible to desync, but we'll account for it anyways
|
||||
self.doc_status |= doc_status[0]
|
||||
await ctx.send_msgs([{"cmd": "Set", "key": f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}", "operations": [
|
||||
{"operation": "replace", "value": self.doc_status}
|
||||
]}])
|
||||
|
||||
weapon_energy = bytearray(weapon_energy)
|
||||
# handle receiving items
|
||||
recv_amount = received_items[0]
|
||||
if recv_amount < len(ctx.items_received):
|
||||
item = ctx.items_received[recv_amount]
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_names.lookup_in_slot(item.item), 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_names.lookup_in_slot(item.location, item.player), recv_amount, len(ctx.items_received)))
|
||||
|
||||
if item.item & 0x120 == 0:
|
||||
# Robot Master Weapon, or Rush
|
||||
new_weapons = item.item & 0xFF
|
||||
weapon_energy[MM3_WEAPONS[new_weapons]] |= 0x9C
|
||||
writes.append((MM3_WEAPON_ENERGY, weapon_energy, "RAM"))
|
||||
writes.append(get_sfx_writes(0x32))
|
||||
elif item.item & 0x20 == 0:
|
||||
# Robot Master Stage Access
|
||||
# Catch the Doc Robo here
|
||||
if item.item & 0x10:
|
||||
ptr = MM3_DOC_ROBOT_UNLOCKED
|
||||
unlocked = doc_robo_unlocked
|
||||
else:
|
||||
ptr = MM3_ROBOT_MASTERS_UNLOCKED
|
||||
unlocked = robot_masters_unlocked
|
||||
new_stages = unlocked[0] | (1 << ((item.item & 0xF) - 1))
|
||||
print(new_stages)
|
||||
writes.append((ptr, new_stages.to_bytes(1, 'little'), "RAM"))
|
||||
writes.append(get_sfx_writes(0x34))
|
||||
writes.append((MM3_RBM_STROBE, b"\x01", "RAM"))
|
||||
else:
|
||||
# append to the queue, so we handle it later
|
||||
self.item_queue.append(item)
|
||||
recv_amount += 1
|
||||
writes.append((MM3_RECEIVED_ITEMS, recv_amount.to_bytes(1, 'little'), "RAM"))
|
||||
|
||||
if energy_link_packet[0]:
|
||||
pickup = energy_link_packet[0]
|
||||
if pickup in (0x64, 0x65):
|
||||
# Health pickups
|
||||
if pickup == 0x65:
|
||||
value = 2
|
||||
else:
|
||||
value = 10
|
||||
exchange_rate = HP_EXCHANGE_RATE
|
||||
elif pickup in (0x66, 0x67):
|
||||
# Weapon Energy
|
||||
if pickup == 0x67:
|
||||
value = 2
|
||||
else:
|
||||
value = 10
|
||||
exchange_rate = WEAPON_EXCHANGE_RATE
|
||||
elif pickup == 0x69:
|
||||
# 1-Up
|
||||
value = 1
|
||||
exchange_rate = ONEUP_EXCHANGE_RATE
|
||||
else:
|
||||
# if we managed to pickup something else, we should just fall through
|
||||
value = 0
|
||||
exchange_rate = 0
|
||||
contribution = (value * exchange_rate) >> 1
|
||||
if contribution:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
|
||||
[{"operation": "add", "value": contribution},
|
||||
{"operation": "max", "value": 0}]}])
|
||||
logger.info(f"Deposited {contribution / HP_EXCHANGE_RATE} health into the pool.")
|
||||
writes.append((MM3_ENERGYLINK, 0x00.to_bytes(1, "little"), "RAM"))
|
||||
|
||||
if self.weapon_energy:
|
||||
# Weapon Energy
|
||||
# We parse the whole thing to spread it as thin as possible
|
||||
current_energy = self.weapon_energy
|
||||
for i, weapon in zip(range(len(weapon_energy)), weapon_energy):
|
||||
if weapon & 0x80 and (weapon & 0x7F) < 0x1C:
|
||||
missing = 0x1C - (weapon & 0x7F)
|
||||
if missing > self.weapon_energy:
|
||||
missing = self.weapon_energy
|
||||
self.weapon_energy -= missing
|
||||
weapon_energy[i] = weapon + missing
|
||||
if not self.weapon_energy:
|
||||
writes.append((MM3_WEAPON_ENERGY, weapon_energy, "RAM"))
|
||||
break
|
||||
else:
|
||||
if current_energy != self.weapon_energy:
|
||||
writes.append((MM3_WEAPON_ENERGY, weapon_energy, "RAM"))
|
||||
|
||||
if self.health_energy or self.auto_heal:
|
||||
# Health Energy
|
||||
# We save this if the player has not taken any damage
|
||||
current_health = health[0]
|
||||
if 0 < (current_health & 0x7F) < 0x1C:
|
||||
health_diff = 0x1C - (current_health & 0x7F)
|
||||
if self.health_energy:
|
||||
if health_diff > self.health_energy:
|
||||
health_diff = self.health_energy
|
||||
self.health_energy -= health_diff
|
||||
else:
|
||||
pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0)
|
||||
if health_diff * HP_EXCHANGE_RATE > pool:
|
||||
health_diff = int(pool // HP_EXCHANGE_RATE)
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
|
||||
[{"operation": "add", "value": -health_diff * HP_EXCHANGE_RATE},
|
||||
{"operation": "max", "value": 0}]}])
|
||||
current_health += health_diff
|
||||
writes.append((MM3_HEALTH, current_health.to_bytes(1, 'little'), "RAM"))
|
||||
|
||||
if self.refill_queue:
|
||||
refill_type, refill_amount = self.refill_queue.pop()
|
||||
if refill_type == MM3EnergyLinkType.Life:
|
||||
exchange_rate = HP_EXCHANGE_RATE
|
||||
elif refill_type == MM3EnergyLinkType.OneUP:
|
||||
exchange_rate = ONEUP_EXCHANGE_RATE
|
||||
else:
|
||||
exchange_rate = WEAPON_EXCHANGE_RATE
|
||||
pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0)
|
||||
request = exchange_rate * refill_amount
|
||||
if request > pool:
|
||||
logger.warning(
|
||||
f"Not enough energy to fulfill the request. Maximum request: {pool // exchange_rate}")
|
||||
else:
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
|
||||
[{"operation": "add", "value": -request},
|
||||
{"operation": "max", "value": 0}]}])
|
||||
if refill_type == MM3EnergyLinkType.Life:
|
||||
refill_ptr = MM3_HEALTH
|
||||
elif refill_type == MM3EnergyLinkType.OneUP:
|
||||
refill_ptr = MM3_LIVES
|
||||
else:
|
||||
refill_ptr = MM3_WEAPON_ENERGY + MM3_WEAPONS[refill_type]
|
||||
current_value = (await read(ctx.bizhawk_ctx, [(refill_ptr, 1, "RAM")]))[0][0]
|
||||
if refill_type == MM3EnergyLinkType.OneUP:
|
||||
current_value = from_oneup_format(current_value)
|
||||
new_value = min(0x9C if refill_type != MM3EnergyLinkType.OneUP else 99, current_value + refill_amount)
|
||||
if refill_type == MM3EnergyLinkType.OneUP:
|
||||
new_value = to_oneup_format(new_value)
|
||||
writes.append((refill_ptr, new_value.to_bytes(1, "little"), "RAM"))
|
||||
|
||||
if len(self.item_queue):
|
||||
item = self.item_queue.pop(0)
|
||||
idx = item.item & 0xF
|
||||
if idx == 0:
|
||||
# 1-Up
|
||||
current_lives = from_oneup_format(lives[0])
|
||||
if current_lives > 99:
|
||||
self.item_queue.append(item)
|
||||
else:
|
||||
current_lives += 1
|
||||
current_lives = to_oneup_format(current_lives)
|
||||
writes.append((MM3_LIVES, current_lives.to_bytes(1, 'little'), "RAM"))
|
||||
writes.append(get_sfx_writes(0x14))
|
||||
elif idx == 1:
|
||||
self.weapon_energy += 0xE
|
||||
writes.append(get_sfx_writes(0x1C))
|
||||
elif idx == 2:
|
||||
self.health_energy += 0xE
|
||||
writes.append(get_sfx_writes(0x1C))
|
||||
elif idx == 3:
|
||||
current_tanks = from_oneup_format(e_tanks[0])
|
||||
if current_tanks > 99:
|
||||
self.item_queue.append(item)
|
||||
else:
|
||||
current_tanks += 1
|
||||
current_tanks = to_oneup_format(current_tanks)
|
||||
writes.append((MM3_E_TANKS, current_tanks.to_bytes(1, 'little'), "RAM"))
|
||||
writes.append(get_sfx_writes(0x14))
|
||||
|
||||
await write(ctx.bizhawk_ctx, writes)
|
||||
|
||||
new_checks = []
|
||||
# check for locations
|
||||
for i in range(8):
|
||||
flag = 1 << i
|
||||
if robot_masters_defeated[0] & flag:
|
||||
rbm_id = 0x0001 + i
|
||||
if rbm_id not in ctx.checked_locations:
|
||||
new_checks.append(rbm_id)
|
||||
wep_id = 0x0101 + i
|
||||
if wep_id not in ctx.checked_locations:
|
||||
new_checks.append(wep_id)
|
||||
if doc_robo_defeated[0] & flag:
|
||||
doc_id = 0x0010 + MM3_DOC_REMAP[i]
|
||||
if doc_id not in ctx.checked_locations:
|
||||
new_checks.append(doc_id)
|
||||
|
||||
for i in range(2):
|
||||
flag = 1 << i
|
||||
if rush_acquired[0] & flag:
|
||||
itm_id = 0x0111 + i
|
||||
if itm_id not in ctx.checked_locations:
|
||||
new_checks.append(itm_id)
|
||||
|
||||
for i in (0, 1, 2, 4):
|
||||
# Wily 4 does not have a boss check
|
||||
boss_id = 0x0009 + i
|
||||
if completed_stages[0] & (1 << i) != 0:
|
||||
if boss_id not in ctx.checked_locations:
|
||||
new_checks.append(boss_id)
|
||||
|
||||
if completed_stages[0] & 0x80 and 0x000F not in ctx.checked_locations:
|
||||
new_checks.append(0x000F)
|
||||
|
||||
if bar_state[0] == 0x80: # currently in stage
|
||||
if (prog_state[0] > 0x00 and current_stage[0] >= 8) or prog_state[0] == 0x00:
|
||||
# need to block the specific state of Break Man prog=0x12 stage=0x5
|
||||
# it doesn't clean the consumable table and he doesn't have any anyways
|
||||
for consumable in MM3_CONSUMABLE_TABLE[current_stage[0]]:
|
||||
consumable_info = MM3_CONSUMABLE_TABLE[current_stage[0]][consumable]
|
||||
if consumable not in ctx.checked_locations:
|
||||
is_checked = consumable_checks[consumable_info[0]] & (1 << consumable_info[1])
|
||||
if is_checked:
|
||||
new_checks.append(consumable)
|
||||
|
||||
for new_check_id in new_checks:
|
||||
ctx.locations_checked.add(new_check_id)
|
||||
location = ctx.location_names.lookup_in_game(new_check_id)
|
||||
nes_logger.info(
|
||||
f'New Check: {location} ({len(ctx.locations_checked)}/'
|
||||
f'{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}])
|
||||
331
worlds/mm3/color.py
Normal file
331
worlds/mm3/color.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from . import names
|
||||
from zlib import crc32
|
||||
import struct
|
||||
import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MM3World
|
||||
from .rom import MM3ProcedurePatch
|
||||
|
||||
HTML_TO_NES: dict[str, int] = {
|
||||
'SNOW': 0x20,
|
||||
'LINEN': 0x36,
|
||||
'SEASHELL': 0x36,
|
||||
'AZURE': 0x3C,
|
||||
'LAVENDER': 0x33,
|
||||
'WHITE': 0x30,
|
||||
'BLACK': 0x0F,
|
||||
'GREY': 0x00,
|
||||
'GRAY': 0x00,
|
||||
'ROYALBLUE': 0x12,
|
||||
'BLUE': 0x11,
|
||||
'SKYBLUE': 0x21,
|
||||
'LIGHTBLUE': 0x31,
|
||||
'TURQUOISE': 0x2B,
|
||||
'CYAN': 0x2C,
|
||||
'AQUAMARINE': 0x3B,
|
||||
'DARKGREEN': 0x0A,
|
||||
'GREEN': 0x1A,
|
||||
'YELLOW': 0x28,
|
||||
'GOLD': 0x28,
|
||||
'WHEAT': 0x37,
|
||||
'TAN': 0x37,
|
||||
'CHOCOLATE': 0x07,
|
||||
'BROWN': 0x07,
|
||||
'SALMON': 0x26,
|
||||
'ORANGE': 0x27,
|
||||
'CORAL': 0x36,
|
||||
'TOMATO': 0x16,
|
||||
'RED': 0x16,
|
||||
'PINK': 0x25,
|
||||
'MAROON': 0x06,
|
||||
'MAGENTA': 0x24,
|
||||
'FUSCHIA': 0x24,
|
||||
'VIOLET': 0x24,
|
||||
'PLUM': 0x33,
|
||||
'PURPLE': 0x14,
|
||||
'THISTLE': 0x34,
|
||||
'DARKBLUE': 0x01,
|
||||
'SILVER': 0x10,
|
||||
'NAVY': 0x02,
|
||||
'TEAL': 0x1C,
|
||||
'OLIVE': 0x18,
|
||||
'LIME': 0x2A,
|
||||
'AQUA': 0x2C,
|
||||
# can add more as needed
|
||||
}
|
||||
|
||||
MM3_COLORS: dict[str, tuple[int, int]] = {
|
||||
names.gemini_laser: (0x30, 0x21),
|
||||
names.needle_cannon: (0x30, 0x17),
|
||||
names.hard_knuckle: (0x10, 0x01),
|
||||
names.magnet_missile: (0x10, 0x16),
|
||||
names.top_spin: (0x36, 0x00),
|
||||
names.search_snake: (0x30, 0x19),
|
||||
names.rush_coil: (0x30, 0x15),
|
||||
names.spark_shock: (0x30, 0x26),
|
||||
names.rush_marine: (0x30, 0x15),
|
||||
names.shadow_blade: (0x34, 0x14),
|
||||
names.rush_jet: (0x30, 0x15),
|
||||
names.needle_man_stage: (0x3C, 0x11),
|
||||
names.magnet_man_stage: (0x30, 0x15),
|
||||
names.gemini_man_stage: (0x30, 0x21),
|
||||
names.hard_man_stage: (0x10, 0xC),
|
||||
names.top_man_stage: (0x30, 0x26),
|
||||
names.snake_man_stage: (0x30, 0x29),
|
||||
names.spark_man_stage: (0x30, 0x26),
|
||||
names.shadow_man_stage: (0x30, 0x11),
|
||||
names.doc_needle_stage: (0x27, 0x15),
|
||||
names.doc_gemini_stage: (0x27, 0x15),
|
||||
names.doc_spark_stage: (0x27, 0x15),
|
||||
names.doc_shadow_stage: (0x27, 0x15),
|
||||
}
|
||||
|
||||
MM3_KNOWN_COLORS: dict[str, tuple[int, int]] = {
|
||||
**MM3_COLORS,
|
||||
# Metroid series
|
||||
"Varia Suit": (0x27, 0x16),
|
||||
"Gravity Suit": (0x14, 0x16),
|
||||
"Phazon Suit": (0x06, 0x1D),
|
||||
# Street Fighter, technically
|
||||
"Hadouken": (0x3C, 0x11),
|
||||
"Shoryuken": (0x38, 0x16),
|
||||
# X Series
|
||||
"Z-Saber": (0x20, 0x16),
|
||||
"Helmet Upgrade": (0x20, 0x01),
|
||||
"Body Upgrade": (0x20, 0x01),
|
||||
"Arms Upgrade": (0x20, 0x01),
|
||||
"Plasma Shot Upgrade": (0x20, 0x01),
|
||||
"Stock Charge Upgrade": (0x20, 0x01),
|
||||
"Legs Upgrade": (0x20, 0x01),
|
||||
# X1
|
||||
"Homing Torpedo": (0x3D, 0x37),
|
||||
"Chameleon Sting": (0x3B, 0x1A),
|
||||
"Rolling Shield": (0x3A, 0x25),
|
||||
"Fire Wave": (0x37, 0x26),
|
||||
"Storm Tornado": (0x34, 0x14),
|
||||
"Electric Spark": (0x3D, 0x28),
|
||||
"Boomerang Cutter": (0x3B, 0x2D),
|
||||
"Shotgun Ice": (0x28, 0x2C),
|
||||
# X2
|
||||
"Crystal Hunter": (0x33, 0x21),
|
||||
"Bubble Splash": (0x35, 0x28),
|
||||
"Spin Wheel": (0x34, 0x1B),
|
||||
"Silk Shot": (0x3B, 0x27),
|
||||
"Sonic Slicer": (0x27, 0x01),
|
||||
"Strike Chain": (0x30, 0x23),
|
||||
"Magnet Mine": (0x28, 0x2D),
|
||||
"Speed Burner": (0x31, 0x16),
|
||||
# X3
|
||||
"Acid Burst": (0x28, 0x2A),
|
||||
"Tornado Fang": (0x28, 0x2C),
|
||||
"Triad Thunder": (0x2B, 0x23),
|
||||
"Spinning Blade": (0x20, 0x16),
|
||||
"Ray Splasher": (0x28, 0x17),
|
||||
"Gravity Well": (0x38, 0x14),
|
||||
"Parasitic Bomb": (0x31, 0x28),
|
||||
"Frost Shield": (0x23, 0x2C),
|
||||
# X4
|
||||
"Lightning Web": (0x3D, 0x28),
|
||||
"Aiming Laser": (0x2C, 0x14),
|
||||
"Double Cyclone": (0x28, 0x1A),
|
||||
"Rising Fire": (0x20, 0x16),
|
||||
"Ground Hunter": (0x2C, 0x15),
|
||||
"Soul Body": (0x37, 0x27),
|
||||
"Twin Slasher": (0x28, 0x00),
|
||||
"Frost Tower": (0x3D, 0x2C),
|
||||
}
|
||||
|
||||
if "worlds.mm2" in sys.modules:
|
||||
# is this the proper way to do this? who knows!
|
||||
try:
|
||||
mm2 = sys.modules["worlds.mm2"]
|
||||
MM3_KNOWN_COLORS.update(mm2.color.MM2_COLORS)
|
||||
for item in MM3_COLORS:
|
||||
mm2.color.add_color_to_mm2(item, MM3_COLORS[item])
|
||||
except AttributeError:
|
||||
# pass through if an old MM2 is found
|
||||
pass
|
||||
|
||||
palette_pointers: dict[str, list[int]] = {
|
||||
"Mega Buster": [0x7C8A8, 0x4650],
|
||||
"Gemini Laser": [0x4654],
|
||||
"Needle Cannon": [0x4658],
|
||||
"Hard Knuckle": [0x465C],
|
||||
"Magnet Missile": [0x4660],
|
||||
"Top Spin": [0x4664],
|
||||
"Search Snake": [0x4668],
|
||||
"Rush Coil": [0x466C],
|
||||
"Spark Shock": [0x4670],
|
||||
"Rush Marine": [0x4674],
|
||||
"Shadow Blade": [0x4678],
|
||||
"Rush Jet": [0x467C],
|
||||
"Needle Man": [0x216C],
|
||||
"Magnet Man": [0x215C],
|
||||
"Gemini Man": [0x217C],
|
||||
"Hard Man": [0x2164],
|
||||
"Top Man": [0x2194],
|
||||
"Snake Man": [0x2174],
|
||||
"Spark Man": [0x2184],
|
||||
"Shadow Man": [0x218C],
|
||||
"Doc Robot": [0x20B8]
|
||||
}
|
||||
|
||||
|
||||
def add_color_to_mm3(name: str, color: tuple[int, int]) -> None:
|
||||
"""
|
||||
Add a color combo for Mega Man 3 to recognize as the color to display for a given item.
|
||||
For information on available colors: https://www.nesdev.org/wiki/PPU_palettes#2C02
|
||||
"""
|
||||
MM3_KNOWN_COLORS[name] = validate_colors(*color)
|
||||
|
||||
|
||||
def extrapolate_color(color: int) -> tuple[int, int]:
|
||||
if color > 0x1F:
|
||||
color_1 = color
|
||||
color_2 = color_1 - 0x10
|
||||
else:
|
||||
color_2 = color
|
||||
color_1 = color_2 + 0x10
|
||||
return color_1, color_2
|
||||
|
||||
|
||||
def validate_colors(color_1: int, color_2: int, allow_match: bool = False) -> tuple[int, int]:
|
||||
# Black should be reserved for outlines, a gray should suffice
|
||||
if color_1 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]:
|
||||
color_1 = 0x10
|
||||
if color_2 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]:
|
||||
color_2 = 0x10
|
||||
|
||||
# one final check, make sure we don't have two matching
|
||||
if not allow_match and color_1 == color_2:
|
||||
color_1 = 0x30 # color 1 to white works with about any paired color
|
||||
|
||||
return color_1, color_2
|
||||
|
||||
|
||||
def expand_colors(color_1: int, color_2: int) -> tuple[tuple[int, int, int], tuple[int, int, int]]:
|
||||
if color_2 >= 0x30:
|
||||
color_a = color_b = color_2
|
||||
else:
|
||||
color_a = color_2 + 0x10
|
||||
color_b = color_2
|
||||
|
||||
if color_1 < 0x10:
|
||||
color_c = color_1 + 0x10
|
||||
color_d = color_1
|
||||
color_e = color_1 + 0x20
|
||||
elif color_1 >= 0x30:
|
||||
color_c = color_1 - 0x10
|
||||
color_d = color_1 - 0x20
|
||||
color_e = color_1
|
||||
else:
|
||||
color_c = color_1
|
||||
color_d = color_1 - 0x10
|
||||
color_e = color_1 + 0x10
|
||||
|
||||
return (0x30, color_a, color_b), (color_d, color_e, color_c)
|
||||
|
||||
|
||||
def get_colors_for_item(name: str) -> tuple[tuple[int, int, int], tuple[int, int, int]]:
|
||||
if name in MM3_KNOWN_COLORS:
|
||||
return expand_colors(*MM3_KNOWN_COLORS[name])
|
||||
|
||||
check_colors = {color: color in name.upper().replace(" ", '') for color in HTML_TO_NES}
|
||||
colors = [color for color in check_colors if check_colors[color]]
|
||||
if colors:
|
||||
# we have at least one color pattern matched
|
||||
if len(colors) > 1:
|
||||
# we have at least 2
|
||||
color_1 = HTML_TO_NES[colors[0]]
|
||||
color_2 = HTML_TO_NES[colors[1]]
|
||||
else:
|
||||
color_1, color_2 = extrapolate_color(HTML_TO_NES[colors[0]])
|
||||
else:
|
||||
# generate hash
|
||||
crc_hash = crc32(name.encode('utf-8'))
|
||||
hash_color = struct.pack("I", crc_hash)
|
||||
color_1 = hash_color[0] % 0x3F
|
||||
color_2 = hash_color[1] % 0x3F
|
||||
|
||||
if color_1 < color_2:
|
||||
temp = color_1
|
||||
color_1 = color_2
|
||||
color_2 = temp
|
||||
|
||||
color_1, color_2 = validate_colors(color_1, color_2)
|
||||
|
||||
return expand_colors(color_1, color_2)
|
||||
|
||||
|
||||
def parse_color(colors: list[str]) -> tuple[int, int]:
|
||||
color_a = colors[0]
|
||||
if color_a.startswith("$"):
|
||||
color_1 = int(color_a[1:], 16)
|
||||
else:
|
||||
# assume it's in our list of colors
|
||||
color_1 = HTML_TO_NES[color_a.upper()]
|
||||
|
||||
if len(colors) == 1:
|
||||
color_1, color_2 = extrapolate_color(color_1)
|
||||
else:
|
||||
color_b = colors[1]
|
||||
if color_b.startswith("$"):
|
||||
color_2 = int(color_b[1:], 16)
|
||||
else:
|
||||
color_2 = HTML_TO_NES[color_b.upper()]
|
||||
return color_1, color_2
|
||||
|
||||
|
||||
def write_palette_shuffle(world: "MM3World", rom: "MM3ProcedurePatch") -> None:
|
||||
palette_shuffle: int | str = world.options.palette_shuffle.value
|
||||
palettes_to_write: dict[str, tuple[int, int]] = {}
|
||||
if isinstance(palette_shuffle, str):
|
||||
color_sets = palette_shuffle.split(";")
|
||||
if len(color_sets) == 1:
|
||||
palette_shuffle = world.options.palette_shuffle.option_none
|
||||
# singularity is more correct, but this is faster
|
||||
else:
|
||||
palette_shuffle = world.options.palette_shuffle.options[color_sets.pop()]
|
||||
for color_set in color_sets:
|
||||
if "-" in color_set:
|
||||
character, color = color_set.split("-")
|
||||
if character.title() not in palette_pointers:
|
||||
logging.warning(f"Player {world.player_name} "
|
||||
f"attempted to set color for unrecognized option {character}")
|
||||
colors = color.split("|")
|
||||
real_colors = validate_colors(*parse_color(colors), allow_match=True)
|
||||
palettes_to_write[character.title()] = real_colors
|
||||
else:
|
||||
# If color is provided with no character, assume singularity
|
||||
colors = color_set.split("|")
|
||||
real_colors = validate_colors(*parse_color(colors), allow_match=True)
|
||||
for character in palette_pointers:
|
||||
palettes_to_write[character] = real_colors
|
||||
# Now we handle the real values
|
||||
if palette_shuffle != 0:
|
||||
if palette_shuffle > 1:
|
||||
if palette_shuffle == 3:
|
||||
# singularity
|
||||
real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F))
|
||||
for character in palette_pointers:
|
||||
if character not in palettes_to_write:
|
||||
palettes_to_write[character] = real_colors
|
||||
else:
|
||||
for character in palette_pointers:
|
||||
if character not in palettes_to_write:
|
||||
real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F))
|
||||
palettes_to_write[character] = real_colors
|
||||
else:
|
||||
shuffled_colors = list(MM3_COLORS.values())[:-3] # only include one Doc Robot
|
||||
shuffled_colors.append((0x2C, 0x11)) # Mega Buster
|
||||
world.random.shuffle(shuffled_colors)
|
||||
for character in palette_pointers:
|
||||
if character not in palettes_to_write:
|
||||
palettes_to_write[character] = shuffled_colors.pop()
|
||||
|
||||
for character in palettes_to_write:
|
||||
for pointer in palette_pointers[character]:
|
||||
rom.write_bytes(pointer + 2, bytes(palettes_to_write[character]))
|
||||
BIN
worlds/mm3/data/mm3_basepatch.bsdiff4
Normal file
BIN
worlds/mm3/data/mm3_basepatch.bsdiff4
Normal file
Binary file not shown.
131
worlds/mm3/docs/en_Mega Man 3.md
Normal file
131
worlds/mm3/docs/en_Mega Man 3.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Mega Man 3
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
The [player options page for this game](../player-options) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Weapons received from Robot Masters, access to each individual stage (including Doc Robot stages), and Items from Dr. Light are randomized
|
||||
into the multiworld. Access to the Wily Stages is locked behind clearing the 4 Doc Robot stages and defeating Break Man. The game is complete upon
|
||||
viewing the ending sequence after defeating Gamma.
|
||||
|
||||
## What Mega Man 3 items can appear in other players' worlds?
|
||||
- Robot Master weapons
|
||||
- Robot Master Access Codes (stage access)
|
||||
- Doc Robot Access Codes (stage access)
|
||||
- Rush Coil/Jet/Marine
|
||||
- 1-Ups
|
||||
- E-Tanks
|
||||
- Health Energy (L)
|
||||
- Weapon Energy (L)
|
||||
|
||||
## What is considered a location check in Mega Man 3?
|
||||
- The defeat of a Robot Master, Doc Robot, or Wily Boss
|
||||
- Receiving a weapon or Rush item from Dr. Light
|
||||
- Optionally, 1-Ups and E-Tanks present within stages
|
||||
- Optionally, Weapon and Health Energy pickups present within stages
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
A sound effect will play based on the type of item received, and the effects of the item will be immediately applied,
|
||||
such as unlocking the use of a weapon mid-stage. If the effects of the item cannot be fully applied (such as receiving
|
||||
Health Energy while at full health), the remaining are withheld until they can be applied.
|
||||
|
||||
## How do I access the Doc Robot stages?
|
||||
By pressing Select on the Robot Master screen, the screen will transition between Robot Masters and
|
||||
Doc Robots.
|
||||
|
||||
## Useful Information
|
||||
* **NesHawk is the recommended core for this game!** Players using QuickNes (or QuickerNes) will experience graphical
|
||||
glitches while in Gemini Man's stage and fighting Gamma.
|
||||
* Pressing A+B+Start+Select while in a stage will take you to the Game Over screen, allowing you to leave the stage.
|
||||
Your E-Tanks will be preserved.
|
||||
* Your current progress through the Wily stages is saved to the multiworld, allowing you to return to the last stage you
|
||||
reached should you need to leave and enter a Robot Master stage. If you need to return to an earlier Wily stage, holding
|
||||
Select while entering Break Man's stage will take you to Wily 1.
|
||||
* When Random Weaknesses are enabled, Break Man's weakness will be changed from Mega Buster to one random weapon.
|
||||
|
||||
|
||||
## What is EnergyLink?
|
||||
EnergyLink is an energy storage supported by certain games that is shared across all worlds in a multiworld. In Mega Man
|
||||
3, when enabled, drops from enemies are not applied directly to Mega Man and are instead deposited into the EnergyLink.
|
||||
Half of the energy that would be gained is lost upon transfer to the EnergyLink.
|
||||
|
||||
Energy from the EnergyLink storage can be converted into health, weapon energy, and lives at different conversion rates.
|
||||
You can find out how much of each type you can pull using `/pool` in the client. Additionally, you can have it
|
||||
automatically pull from the EnergyLink storage to keep Mega Man healed using the `/autoheal` command in the client.
|
||||
Finally, you can use the `/request` command to request a certain type of energy from the storage.
|
||||
|
||||
## Plando Palettes
|
||||
The palette shuffle option supports specifying a specific palette for a given weapon/Robot Master. The format for doing
|
||||
so is `Character-Color1|Color2;Option`. Character is the individual that this should apply to, and can only be one of
|
||||
the following:
|
||||
- Mega Buster
|
||||
- Gemini Laser
|
||||
- Needle Cannon
|
||||
- Hard Knuckle
|
||||
- Magnet Missile
|
||||
- Top Spin
|
||||
- Search Snake
|
||||
- Spark Shot
|
||||
- Shadow Blade
|
||||
- Rush Coil
|
||||
- Rush Jet
|
||||
- Rush Marine
|
||||
- Needle Man
|
||||
- Magnet Man
|
||||
- Gemini Man
|
||||
- Hard Man
|
||||
- Top Man
|
||||
- Snake Man
|
||||
- Spark Man
|
||||
- Shadow Man
|
||||
- Doc Robot
|
||||
|
||||
Colors attempt to map a list of HTML-defined colors to what the NES can render. A full list of applicable colors can be
|
||||
found [here](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/mm2/Color.py#L11). Alternatively, colors can
|
||||
be supplied directly using `$xx` format. A full list of NES colors can be found [here](https://www.nesdev.org/wiki/PPU_palettes#2C02).
|
||||
|
||||
You can also pass only one color (such as `Mega Buster-Red`) and it will interpret a second color based off of the color
|
||||
given. Additionally, passing only colors (such as `Red|Blue`) and not any specific boss/weapon will apply that color to
|
||||
all weapons/bosses that did not have a prior color specified.
|
||||
|
||||
The option is the method to be used to set the palettes of the remaining bosses/weapons, and will not overwrite any
|
||||
plando placements.
|
||||
|
||||
## Plando Weaknesses
|
||||
Plando Weaknesses allows you to override the amount of damage a boss should take from a given weapon, ignoring prior
|
||||
weaknesses generated by strict/random weakness options. Formatting for this is as follows:
|
||||
```yaml
|
||||
plando_weakness:
|
||||
Needle Man:
|
||||
Top Spin: 0
|
||||
Hard Knuckle: 4
|
||||
```
|
||||
This would cause Air Man to take 4 damage from Hard Knuckle, and 0 from Top Spin.
|
||||
|
||||
Note: it is possible that plando weakness is not be respected should the plando create a situation in which the game
|
||||
becomes impossible to complete. In this situation, the damage would be boosted to the minimum required to defeat the
|
||||
Robot Master.
|
||||
|
||||
|
||||
## Unique Local Commands
|
||||
- `/pool` Only present with EnergyLink, prints the max amount of each type of request that could be fulfilled.
|
||||
- `/autoheal` Only present with EnergyLink, will automatically drain energy from the EnergyLink in order to
|
||||
restore Mega Man's health.
|
||||
- `/request <amount> <type>` Only present with EnergyLink, sends a request of a certain type of energy to be pulled from
|
||||
the EnergyLink. Types are as follows:
|
||||
- `HP` Health
|
||||
- `NE` Needle Cannon
|
||||
- `MA` Magnet Missile
|
||||
- `GE` Gemini Laser
|
||||
- `HA` Hard Knuckle
|
||||
- `TO` Top Spin
|
||||
- `SN` Search Snake
|
||||
- `SP` Spark Shot
|
||||
- `SH` Shadow Blade
|
||||
- `RC` Rush Coil
|
||||
- `RM` Rush Marine
|
||||
- `RJ` Rush Jet
|
||||
- `1U` Lives
|
||||
53
worlds/mm3/docs/setup_en.md
Normal file
53
worlds/mm3/docs/setup_en.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Mega Man 3 Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- An English Mega Man 3 ROM. Alternatively, the [Mega Man Legacy Collection](https://store.steampowered.com/app/363440/Mega_Man_Legacy_Collection/) on Steam.
|
||||
- [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 or later. Bizhawk 2.10
|
||||
|
||||
### Configuring Bizhawk
|
||||
|
||||
Once you have installed BizHawk, open `EmuHawk.exe` and change the following settings:
|
||||
|
||||
- If you're using BizHawk 2.7 or 2.8, go to `Config > Customize`. On the Advanced tab, switch the Lua Core from
|
||||
`NLua+KopiLua` to `Lua+LuaInterface`, then restart EmuHawk. (If you're using BizHawk 2.9, you can skip this step.)
|
||||
- Under `Config > Customize`, check the "Run in background" option to prevent disconnecting from the client while you're
|
||||
tabbed out of EmuHawk.
|
||||
- Open a `.nes` file in EmuHawk and go to `Config > Controllers…` to configure your inputs. If you can't click
|
||||
`Controllers…`, load any `.nes` ROM first.
|
||||
- Consider clearing keybinds in `Config > Hotkeys…` if you don't intend to use them. Select the keybind and press Esc to
|
||||
clear it.
|
||||
|
||||
## Generating and Patching a Game
|
||||
|
||||
1. Create your options file (YAML). You can make one on the
|
||||
[Mega Man 3 options page](../../../games/Mega%20Man%203/player-options).
|
||||
2. Follow the general Archipelago instructions for [generating a game](../../Archipelago/setup/en#generating-a-game).
|
||||
This will generate an output file for you. Your patch file will have the `.apmm3` file extension.
|
||||
3. Open `ArchipelagoLauncher.exe`
|
||||
4. Select "Open Patch" on the left side and select your patch file.
|
||||
5. If this is your first time patching, you will be prompted to locate your vanilla ROM. If you are using the Legacy
|
||||
Collection, provide `Proteus.exe` in place of your rom.
|
||||
6. A patched `.nes` file will be created in the same place as the patch file.
|
||||
7. On your first time opening a patch with BizHawk Client, you will also be asked to locate `EmuHawk.exe` in your
|
||||
BizHawk install.
|
||||
|
||||
## Connecting to a Server
|
||||
|
||||
By default, opening a patch file will do steps 1-5 below for you automatically. Even so, keep them in your memory just
|
||||
in case you have to close and reopen a window mid-game for some reason.
|
||||
|
||||
1. Mega Man 3 uses Archipelago's BizHawk Client. If the client isn't still open from when you patched your game,
|
||||
you can re-open it from the launcher.
|
||||
2. Ensure EmuHawk is running the patched ROM.
|
||||
3. In EmuHawk, go to `Tools > Lua Console`. This window must stay open while playing.
|
||||
4. In the Lua Console window, go to `Script > Open Script…`.
|
||||
5. Navigate to your Archipelago install folder and open `data/lua/connector_bizhawk_generic.lua`.
|
||||
6. The emulator and client will eventually connect to each other. The BizHawk Client window should indicate that it
|
||||
connected and recognized Mega Man 3.
|
||||
7. To connect the client to the server, enter your room's address and port (e.g. `archipelago.gg:38281`) into the
|
||||
top text field of the client and click Connect.
|
||||
|
||||
You should now be able to receive and send items. You'll need to do these steps every time you want to reconnect. It is
|
||||
perfectly safe to make progress offline; everything will re-sync when you reconnect.
|
||||
80
worlds/mm3/items.py
Normal file
80
worlds/mm3/items.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from BaseClasses import Item
|
||||
from typing import NamedTuple
|
||||
from .names import (needle_cannon, magnet_missile, gemini_laser, hard_knuckle, top_spin, search_snake, spark_shock,
|
||||
shadow_blade, rush_coil, rush_marine, rush_jet, needle_man_stage, magnet_man_stage,
|
||||
gemini_man_stage, hard_man_stage, top_man_stage, snake_man_stage, spark_man_stage, shadow_man_stage,
|
||||
doc_needle_stage, doc_gemini_stage, doc_spark_stage, doc_shadow_stage, e_tank, weapon_energy,
|
||||
health_energy, one_up)
|
||||
|
||||
|
||||
class ItemData(NamedTuple):
|
||||
code: int
|
||||
progression: bool
|
||||
useful: bool = False # primarily use this for incredibly useful items of their class, like Metal Blade
|
||||
skip_balancing: bool = False
|
||||
|
||||
|
||||
class MM3Item(Item):
|
||||
game = "Mega Man 3"
|
||||
|
||||
|
||||
robot_master_weapon_table = {
|
||||
needle_cannon: ItemData(0x0001, True),
|
||||
magnet_missile: ItemData(0x0002, True, True),
|
||||
gemini_laser: ItemData(0x0003, True),
|
||||
hard_knuckle: ItemData(0x0004, True),
|
||||
top_spin: ItemData(0x0005, True, True),
|
||||
search_snake: ItemData(0x0006, True),
|
||||
spark_shock: ItemData(0x0007, True),
|
||||
shadow_blade: ItemData(0x0008, True, True),
|
||||
}
|
||||
|
||||
stage_access_table = {
|
||||
needle_man_stage: ItemData(0x0101, True),
|
||||
magnet_man_stage: ItemData(0x0102, True),
|
||||
gemini_man_stage: ItemData(0x0103, True),
|
||||
hard_man_stage: ItemData(0x0104, True),
|
||||
top_man_stage: ItemData(0x0105, True),
|
||||
snake_man_stage: ItemData(0x0106, True),
|
||||
spark_man_stage: ItemData(0x0107, True),
|
||||
shadow_man_stage: ItemData(0x0108, True),
|
||||
doc_needle_stage: ItemData(0x0111, True, True),
|
||||
doc_gemini_stage: ItemData(0x0113, True, True),
|
||||
doc_spark_stage: ItemData(0x0117, True, True),
|
||||
doc_shadow_stage: ItemData(0x0118, True, True),
|
||||
}
|
||||
|
||||
rush_item_table = {
|
||||
rush_coil: ItemData(0x0011, True, True),
|
||||
rush_marine: ItemData(0x0012, True),
|
||||
rush_jet: ItemData(0x0013, True, True),
|
||||
}
|
||||
|
||||
filler_item_table = {
|
||||
one_up: ItemData(0x0020, False),
|
||||
weapon_energy: ItemData(0x0021, False),
|
||||
health_energy: ItemData(0x0022, False),
|
||||
e_tank: ItemData(0x0023, False, True),
|
||||
}
|
||||
|
||||
filler_item_weights = {
|
||||
one_up: 1,
|
||||
weapon_energy: 4,
|
||||
health_energy: 1,
|
||||
e_tank: 2,
|
||||
}
|
||||
|
||||
item_table = {
|
||||
**robot_master_weapon_table,
|
||||
**stage_access_table,
|
||||
**rush_item_table,
|
||||
**filler_item_table,
|
||||
}
|
||||
|
||||
item_names = {
|
||||
"Weapons": {name for name in robot_master_weapon_table.keys()},
|
||||
"Stages": {name for name in stage_access_table.keys()},
|
||||
"Rush": {name for name in rush_item_table.keys()}
|
||||
}
|
||||
|
||||
lookup_item_to_id: dict[str, int] = {item_name: data.code for item_name, data in item_table.items() if data.code}
|
||||
312
worlds/mm3/locations.py
Normal file
312
worlds/mm3/locations.py
Normal file
@@ -0,0 +1,312 @@
|
||||
from BaseClasses import Location, Region
|
||||
from typing import NamedTuple
|
||||
from . import names
|
||||
|
||||
|
||||
class MM3Location(Location):
|
||||
game = "Mega Man 3"
|
||||
|
||||
|
||||
class MM3Region(Region):
|
||||
game = "Mega Man 3"
|
||||
|
||||
|
||||
class LocationData(NamedTuple):
|
||||
location_id: int | None
|
||||
energy: bool = False
|
||||
oneup_tank: bool = False
|
||||
|
||||
|
||||
class RegionData(NamedTuple):
|
||||
locations: dict[str, LocationData]
|
||||
required_items: list[str]
|
||||
parent: str = ""
|
||||
|
||||
mm3_regions: dict[str, RegionData] = {
|
||||
"Needle Man Stage": RegionData({
|
||||
names.needle_man: LocationData(0x0001),
|
||||
names.get_needle_cannon: LocationData(0x0101),
|
||||
names.get_rush_jet: LocationData(0x0111),
|
||||
names.needle_man_c1: LocationData(0x0200, energy=True),
|
||||
names.needle_man_c2: LocationData(0x0201, oneup_tank=True),
|
||||
}, [names.needle_man_stage]),
|
||||
|
||||
"Magnet Man Stage": RegionData({
|
||||
names.magnet_man: LocationData(0x0002),
|
||||
names.get_magnet_missile: LocationData(0x0102),
|
||||
names.magnet_man_c1: LocationData(0x0202, energy=True),
|
||||
names.magnet_man_c2: LocationData(0x0203, energy=True),
|
||||
names.magnet_man_c3: LocationData(0x0204, energy=True),
|
||||
names.magnet_man_c4: LocationData(0x0205, energy=True),
|
||||
names.magnet_man_c5: LocationData(0x0206, energy=True),
|
||||
names.magnet_man_c6: LocationData(0x0207, energy=True),
|
||||
names.magnet_man_c7: LocationData(0x0208, energy=True),
|
||||
names.magnet_man_c8: LocationData(0x0209, energy=True),
|
||||
}, [names.magnet_man_stage]),
|
||||
|
||||
"Gemini Man Stage": RegionData({
|
||||
names.gemini_man: LocationData(0x0003),
|
||||
names.get_gemini_laser: LocationData(0x0103),
|
||||
names.gemini_man_c1: LocationData(0x020A, oneup_tank=True),
|
||||
names.gemini_man_c2: LocationData(0x020B, energy=True),
|
||||
names.gemini_man_c3: LocationData(0x020C, oneup_tank=True),
|
||||
names.gemini_man_c4: LocationData(0x020D, energy=True),
|
||||
names.gemini_man_c5: LocationData(0x020E, energy=True),
|
||||
names.gemini_man_c6: LocationData(0x020F, oneup_tank=True),
|
||||
names.gemini_man_c7: LocationData(0x0210, oneup_tank=True),
|
||||
names.gemini_man_c8: LocationData(0x0211, energy=True),
|
||||
names.gemini_man_c9: LocationData(0x0212, energy=True),
|
||||
names.gemini_man_c10: LocationData(0x0213, oneup_tank=True),
|
||||
}, [names.gemini_man_stage]),
|
||||
|
||||
"Hard Man Stage": RegionData({
|
||||
names.hard_man: LocationData(0x0004),
|
||||
names.get_hard_knuckle: LocationData(0x0104),
|
||||
names.hard_man_c1: LocationData(0x0214, energy=True),
|
||||
names.hard_man_c2: LocationData(0x0215, energy=True),
|
||||
names.hard_man_c3: LocationData(0x0216, oneup_tank=True),
|
||||
names.hard_man_c4: LocationData(0x0217, energy=True),
|
||||
names.hard_man_c5: LocationData(0x0218, energy=True),
|
||||
names.hard_man_c6: LocationData(0x0219, energy=True),
|
||||
names.hard_man_c7: LocationData(0x021A, energy=True),
|
||||
}, [names.hard_man_stage]),
|
||||
|
||||
"Top Man Stage": RegionData({
|
||||
names.top_man: LocationData(0x0005),
|
||||
names.get_top_spin: LocationData(0x0105),
|
||||
names.top_man_c1: LocationData(0x021B, energy=True),
|
||||
names.top_man_c2: LocationData(0x021C, energy=True),
|
||||
names.top_man_c3: LocationData(0x021D, energy=True),
|
||||
names.top_man_c4: LocationData(0x021E, energy=True),
|
||||
names.top_man_c5: LocationData(0x021F, energy=True),
|
||||
names.top_man_c6: LocationData(0x0220, oneup_tank=True),
|
||||
names.top_man_c7: LocationData(0x0221, energy=True),
|
||||
names.top_man_c8: LocationData(0x0222, energy=True),
|
||||
}, [names.top_man_stage]),
|
||||
|
||||
"Snake Man Stage": RegionData({
|
||||
names.snake_man: LocationData(0x0006),
|
||||
names.get_search_snake: LocationData(0x0106),
|
||||
names.snake_man_c1: LocationData(0x0223, energy=True),
|
||||
names.snake_man_c2: LocationData(0x0224, energy=True),
|
||||
names.snake_man_c3: LocationData(0x0225, oneup_tank=True),
|
||||
names.snake_man_c4: LocationData(0x0226, oneup_tank=True),
|
||||
names.snake_man_c5: LocationData(0x0227, energy=True),
|
||||
}, [names.snake_man_stage]),
|
||||
|
||||
"Spark Man Stage": RegionData({
|
||||
names.spark_man: LocationData(0x0007),
|
||||
names.get_spark_shock: LocationData(0x0107),
|
||||
names.spark_man_c1: LocationData(0x0228, energy=True),
|
||||
names.spark_man_c2: LocationData(0x0229, energy=True),
|
||||
names.spark_man_c3: LocationData(0x022A, energy=True),
|
||||
names.spark_man_c4: LocationData(0x022B, energy=True),
|
||||
names.spark_man_c5: LocationData(0x022C, energy=True),
|
||||
names.spark_man_c6: LocationData(0x022D, energy=True),
|
||||
}, [names.spark_man_stage]),
|
||||
|
||||
"Shadow Man Stage": RegionData({
|
||||
names.shadow_man: LocationData(0x0008),
|
||||
names.get_shadow_blade: LocationData(0x0108),
|
||||
names.get_rush_marine: LocationData(0x0112),
|
||||
names.shadow_man_c1: LocationData(0x022E, energy=True),
|
||||
names.shadow_man_c2: LocationData(0x022F, energy=True),
|
||||
names.shadow_man_c3: LocationData(0x0230, energy=True),
|
||||
names.shadow_man_c4: LocationData(0x0231, energy=True),
|
||||
}, [names.shadow_man_stage]),
|
||||
|
||||
"Doc Robot (Needle) - Air": RegionData({
|
||||
names.doc_air: LocationData(0x0010),
|
||||
names.doc_needle_c1: LocationData(0x0232, energy=True),
|
||||
names.doc_needle_c2: LocationData(0x0233, oneup_tank=True),
|
||||
names.doc_needle_c3: LocationData(0x0234, oneup_tank=True),
|
||||
}, [names.doc_needle_stage]),
|
||||
|
||||
"Doc Robot (Needle) - Crash": RegionData({
|
||||
names.doc_crash: LocationData(0x0011),
|
||||
names.doc_needle: LocationData(None),
|
||||
names.doc_needle_c4: LocationData(0x0235, energy=True),
|
||||
names.doc_needle_c5: LocationData(0x0236, energy=True),
|
||||
names.doc_needle_c6: LocationData(0x0237, energy=True),
|
||||
names.doc_needle_c7: LocationData(0x0238, energy=True),
|
||||
names.doc_needle_c8: LocationData(0x0239, energy=True),
|
||||
names.doc_needle_c9: LocationData(0x023A, energy=True),
|
||||
names.doc_needle_c10: LocationData(0x023B, energy=True),
|
||||
names.doc_needle_c11: LocationData(0x023C, energy=True),
|
||||
}, [], parent="Doc Robot (Needle) - Air"),
|
||||
|
||||
"Doc Robot (Gemini) - Flash": RegionData({
|
||||
names.doc_flash: LocationData(0x0012),
|
||||
names.doc_gemini_c1: LocationData(0x023D, oneup_tank=True),
|
||||
names.doc_gemini_c2: LocationData(0x023E, oneup_tank=True),
|
||||
}, [names.doc_gemini_stage]),
|
||||
|
||||
"Doc Robot (Gemini) - Bubble": RegionData({
|
||||
names.doc_bubble: LocationData(0x0013),
|
||||
names.doc_gemini: LocationData(None),
|
||||
names.doc_gemini_c3: LocationData(0x023F, energy=True),
|
||||
names.doc_gemini_c4: LocationData(0x0240, energy=True),
|
||||
}, [], parent="Doc Robot (Gemini) - Flash"),
|
||||
|
||||
"Doc Robot (Shadow) - Wood": RegionData({
|
||||
names.doc_wood: LocationData(0x0014),
|
||||
}, [names.doc_shadow_stage]),
|
||||
|
||||
"Doc Robot (Shadow) - Heat": RegionData({
|
||||
names.doc_heat: LocationData(0x0015),
|
||||
names.doc_shadow: LocationData(None),
|
||||
names.doc_shadow_c1: LocationData(0x0243, energy=True),
|
||||
names.doc_shadow_c2: LocationData(0x0244, energy=True),
|
||||
names.doc_shadow_c3: LocationData(0x0245, energy=True),
|
||||
names.doc_shadow_c4: LocationData(0x0246, energy=True),
|
||||
names.doc_shadow_c5: LocationData(0x0247, energy=True),
|
||||
}, [], parent="Doc Robot (Shadow) - Wood"),
|
||||
|
||||
"Doc Robot (Spark) - Metal": RegionData({
|
||||
names.doc_metal: LocationData(0x0016),
|
||||
names.doc_spark_c1: LocationData(0x0241, energy=True),
|
||||
}, [names.doc_spark_stage]),
|
||||
|
||||
"Doc Robot (Spark) - Quick": RegionData({
|
||||
names.doc_quick: LocationData(0x0017),
|
||||
names.doc_spark: LocationData(None),
|
||||
names.doc_spark_c2: LocationData(0x0242, energy=True),
|
||||
}, [], parent="Doc Robot (Spark) - Metal"),
|
||||
|
||||
"Break Man": RegionData({
|
||||
names.break_man: LocationData(0x000F),
|
||||
names.break_stage: LocationData(None),
|
||||
}, [names.doc_needle, names.doc_gemini, names.doc_spark, names.doc_shadow]),
|
||||
|
||||
"Wily Stage 1": RegionData({
|
||||
names.wily_1_boss: LocationData(0x0009),
|
||||
names.wily_stage_1: LocationData(None),
|
||||
names.wily_1_c1: LocationData(0x0248, oneup_tank=True),
|
||||
names.wily_1_c2: LocationData(0x0249, oneup_tank=True),
|
||||
names.wily_1_c3: LocationData(0x024A, energy=True),
|
||||
names.wily_1_c4: LocationData(0x024B, oneup_tank=True),
|
||||
names.wily_1_c5: LocationData(0x024C, energy=True),
|
||||
names.wily_1_c6: LocationData(0x024D, energy=True),
|
||||
names.wily_1_c7: LocationData(0x024E, energy=True),
|
||||
names.wily_1_c8: LocationData(0x024F, oneup_tank=True),
|
||||
names.wily_1_c9: LocationData(0x0250, energy=True),
|
||||
names.wily_1_c10: LocationData(0x0251, energy=True),
|
||||
names.wily_1_c11: LocationData(0x0252, energy=True),
|
||||
names.wily_1_c12: LocationData(0x0253, energy=True),
|
||||
}, [names.break_stage], parent="Break Man"),
|
||||
|
||||
"Wily Stage 2": RegionData({
|
||||
names.wily_2_boss: LocationData(0x000A),
|
||||
names.wily_stage_2: LocationData(None),
|
||||
names.wily_2_c1: LocationData(0x0254, energy=True),
|
||||
names.wily_2_c2: LocationData(0x0255, energy=True),
|
||||
names.wily_2_c3: LocationData(0x0256, oneup_tank=True),
|
||||
names.wily_2_c4: LocationData(0x0257, energy=True),
|
||||
names.wily_2_c5: LocationData(0x0258, energy=True),
|
||||
names.wily_2_c6: LocationData(0x0259, energy=True),
|
||||
names.wily_2_c7: LocationData(0x025A, energy=True),
|
||||
names.wily_2_c8: LocationData(0x025B, energy=True),
|
||||
names.wily_2_c9: LocationData(0x025C, oneup_tank=True),
|
||||
names.wily_2_c10: LocationData(0x025D, energy=True),
|
||||
names.wily_2_c11: LocationData(0x025E, oneup_tank=True),
|
||||
names.wily_2_c12: LocationData(0x025F, energy=True),
|
||||
names.wily_2_c13: LocationData(0x0260, energy=True),
|
||||
}, [names.wily_stage_1], parent="Wily Stage 1"),
|
||||
|
||||
"Wily Stage 3": RegionData({
|
||||
names.wily_3_boss: LocationData(0x000B),
|
||||
names.wily_stage_3: LocationData(None),
|
||||
names.wily_3_c1: LocationData(0x0261, energy=True),
|
||||
names.wily_3_c2: LocationData(0x0262, energy=True),
|
||||
names.wily_3_c3: LocationData(0x0263, oneup_tank=True),
|
||||
names.wily_3_c4: LocationData(0x0264, oneup_tank=True),
|
||||
names.wily_3_c5: LocationData(0x0265, energy=True),
|
||||
names.wily_3_c6: LocationData(0x0266, energy=True),
|
||||
names.wily_3_c7: LocationData(0x0267, energy=True),
|
||||
names.wily_3_c8: LocationData(0x0268, energy=True),
|
||||
names.wily_3_c9: LocationData(0x0269, energy=True),
|
||||
names.wily_3_c10: LocationData(0x026A, oneup_tank=True),
|
||||
names.wily_3_c11: LocationData(0x026B, oneup_tank=True)
|
||||
}, [names.wily_stage_2], parent="Wily Stage 2"),
|
||||
|
||||
"Wily Stage 4": RegionData({
|
||||
names.wily_stage_4: LocationData(None),
|
||||
names.wily_4_c1: LocationData(0x026C, energy=True),
|
||||
names.wily_4_c2: LocationData(0x026D, energy=True),
|
||||
names.wily_4_c3: LocationData(0x026E, energy=True),
|
||||
names.wily_4_c4: LocationData(0x026F, energy=True),
|
||||
names.wily_4_c5: LocationData(0x0270, energy=True),
|
||||
names.wily_4_c6: LocationData(0x0271, energy=True),
|
||||
names.wily_4_c7: LocationData(0x0272, energy=True),
|
||||
names.wily_4_c8: LocationData(0x0273, energy=True),
|
||||
names.wily_4_c9: LocationData(0x0274, energy=True),
|
||||
names.wily_4_c10: LocationData(0x0275, oneup_tank=True),
|
||||
names.wily_4_c11: LocationData(0x0276, energy=True),
|
||||
names.wily_4_c12: LocationData(0x0277, oneup_tank=True),
|
||||
names.wily_4_c13: LocationData(0x0278, energy=True),
|
||||
names.wily_4_c14: LocationData(0x0279, energy=True),
|
||||
names.wily_4_c15: LocationData(0x027A, energy=True),
|
||||
names.wily_4_c16: LocationData(0x027B, energy=True),
|
||||
names.wily_4_c17: LocationData(0x027C, energy=True),
|
||||
names.wily_4_c18: LocationData(0x027D, energy=True),
|
||||
names.wily_4_c19: LocationData(0x027E, energy=True),
|
||||
names.wily_4_c20: LocationData(0x027F, energy=True),
|
||||
}, [names.wily_stage_3], parent="Wily Stage 3"),
|
||||
|
||||
"Wily Stage 5": RegionData({
|
||||
names.wily_5_boss: LocationData(0x000D),
|
||||
names.wily_stage_5: LocationData(None),
|
||||
names.wily_5_c1: LocationData(0x0280, energy=True),
|
||||
names.wily_5_c2: LocationData(0x0281, energy=True),
|
||||
names.wily_5_c3: LocationData(0x0282, oneup_tank=True),
|
||||
names.wily_5_c4: LocationData(0x0283, oneup_tank=True),
|
||||
}, [names.wily_stage_4], parent="Wily Stage 4"),
|
||||
|
||||
"Wily Stage 6": RegionData({
|
||||
names.gamma: LocationData(None),
|
||||
names.wily_6_c1: LocationData(0x0284, oneup_tank=True),
|
||||
names.wily_6_c2: LocationData(0x0285, oneup_tank=True),
|
||||
names.wily_6_c3: LocationData(0x0286, energy=True),
|
||||
names.wily_6_c4: LocationData(0x0287, energy=True),
|
||||
names.wily_6_c5: LocationData(0x0288, oneup_tank=True),
|
||||
names.wily_6_c6: LocationData(0x0289, oneup_tank=True),
|
||||
names.wily_6_c7: LocationData(0x028A, energy=True),
|
||||
}, [names.wily_stage_5], parent="Wily Stage 5"),
|
||||
}
|
||||
|
||||
|
||||
def get_boss_locations(region: str) -> list[str]:
|
||||
return [location for location, data in mm3_regions[region].locations.items()
|
||||
if not data.energy and not data.oneup_tank]
|
||||
|
||||
|
||||
def get_energy_locations(region: str) -> list[str]:
|
||||
return [location for location, data in mm3_regions[region].locations.items() if data.energy]
|
||||
|
||||
|
||||
def get_oneup_locations(region: str) -> list[str]:
|
||||
return [location for location, data in mm3_regions[region].locations.items() if data.oneup_tank]
|
||||
|
||||
|
||||
location_table: dict[str, int | None] = {
|
||||
location: data.location_id for region in mm3_regions.values() for location, data in region.locations.items()
|
||||
}
|
||||
|
||||
|
||||
location_groups = {
|
||||
"Get Equipped": {
|
||||
names.get_needle_cannon,
|
||||
names.get_magnet_missile,
|
||||
names.get_gemini_laser,
|
||||
names.get_hard_knuckle,
|
||||
names.get_top_spin,
|
||||
names.get_search_snake,
|
||||
names.get_spark_shock,
|
||||
names.get_shadow_blade,
|
||||
names.get_rush_marine,
|
||||
names.get_rush_jet,
|
||||
},
|
||||
**{name: {location for location, data in region.locations.items() if data.location_id} for name, region in mm3_regions.items()}
|
||||
}
|
||||
|
||||
lookup_location_to_id: dict[str, int] = {location: idx for location, idx in location_table.items() if idx is not None}
|
||||
221
worlds/mm3/names.py
Normal file
221
worlds/mm3/names.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# Robot Master Weapons
|
||||
gemini_laser = "Gemini Laser"
|
||||
needle_cannon = "Needle Cannon"
|
||||
hard_knuckle = "Hard Knuckle"
|
||||
magnet_missile = "Magnet Missile"
|
||||
top_spin = "Top Spin"
|
||||
search_snake = "Search Snake"
|
||||
spark_shock = "Spark Shock"
|
||||
shadow_blade = "Shadow Blade"
|
||||
|
||||
# Rush
|
||||
rush_coil = "Rush Coil"
|
||||
rush_jet = "Rush Jet"
|
||||
rush_marine = "Rush Marine"
|
||||
|
||||
# Access Codes
|
||||
needle_man_stage = "Needle Man Access Codes"
|
||||
magnet_man_stage = "Magnet Man Access Codes"
|
||||
gemini_man_stage = "Gemini Man Access Codes"
|
||||
hard_man_stage = "Hard Man Access Codes"
|
||||
top_man_stage = "Top Man Access Codes"
|
||||
snake_man_stage = "Snake Man Access Codes"
|
||||
spark_man_stage = "Spark Man Access Codes"
|
||||
shadow_man_stage = "Shadow Man Access Codes"
|
||||
doc_needle_stage = "Doc Robot (Needle) Access Codes"
|
||||
doc_gemini_stage = "Doc Robot (Gemini) Access Codes"
|
||||
doc_spark_stage = "Doc Robot (Spark) Access Codes"
|
||||
doc_shadow_stage = "Doc Robot (Shadow) Access Codes"
|
||||
|
||||
# Misc. Items
|
||||
one_up = "1-Up"
|
||||
weapon_energy = "Weapon Energy (L)"
|
||||
health_energy = "Health Energy (L)"
|
||||
e_tank = "E-Tank"
|
||||
|
||||
needle_man = "Needle Man - Defeated"
|
||||
magnet_man = "Magnet Man - Defeated"
|
||||
gemini_man = "Gemini Man - Defeated"
|
||||
hard_man = "Hard Man - Defeated"
|
||||
top_man = "Top Man - Defeated"
|
||||
snake_man = "Snake Man - Defeated"
|
||||
spark_man = "Spark Man - Defeated"
|
||||
shadow_man = "Shadow Man - Defeated"
|
||||
doc_air = "Doc Robot (Air) - Defeated"
|
||||
doc_crash = "Doc Robot (Crash) - Defeated"
|
||||
doc_flash = "Doc Robot (Flash) - Defeated"
|
||||
doc_bubble = "Doc Robot (Bubble) - Defeated"
|
||||
doc_wood = "Doc Robot (Wood) - Defeated"
|
||||
doc_heat = "Doc Robot (Heat) - Defeated"
|
||||
doc_metal = "Doc Robot (Metal) - Defeated"
|
||||
doc_quick = "Doc Robot (Quick) - Defeated"
|
||||
break_man = "Break Man - Defeated"
|
||||
wily_1_boss = "Kamegoro Maker - Defeated"
|
||||
wily_2_boss = "Yellow Devil MK-II - Defeated"
|
||||
wily_3_boss = "Holograph Mega Man - Defeated"
|
||||
wily_5_boss = "Wily Machine 3 - Defeated"
|
||||
gamma = "Gamma - Defeated"
|
||||
|
||||
get_gemini_laser = "Gemini Laser - Received"
|
||||
get_needle_cannon = "Needle Cannon - Received"
|
||||
get_hard_knuckle = "Hard Knuckle - Received"
|
||||
get_magnet_missile = "Magnet Missile - Received"
|
||||
get_top_spin = "Top Spin - Received"
|
||||
get_search_snake = "Search Snake - Received"
|
||||
get_spark_shock = "Spark Shock - Received"
|
||||
get_shadow_blade = "Shadow Blade - Received"
|
||||
get_rush_jet = "Rush Jet - Received"
|
||||
get_rush_marine = "Rush Marine - Received"
|
||||
|
||||
# Wily Stage Event Items
|
||||
doc_needle = "Doc Robot (Needle) - Completed"
|
||||
doc_gemini = "Doc Robot (Gemini) - Completed"
|
||||
doc_spark = "Doc Robot (Spark) - Completed"
|
||||
doc_shadow = "Doc Robot (Shadow) - Completed"
|
||||
break_stage = "Break Man"
|
||||
wily_stage_1 = "Wily Stage 1 - Completed"
|
||||
wily_stage_2 = "Wily Stage 2 - Completed"
|
||||
wily_stage_3 = "Wily Stage 3 - Completed"
|
||||
wily_stage_4 = "Wily Stage 4 - Completed"
|
||||
wily_stage_5 = "Wily Stage 5 - Completed"
|
||||
|
||||
# Consumable Locations
|
||||
needle_man_c1 = "Needle Man Stage - Weapon Energy 1"
|
||||
needle_man_c2 = "Needle Man Stage - E-Tank"
|
||||
magnet_man_c1 = "Magnet Man Stage - Health Energy 1"
|
||||
magnet_man_c2 = "Magnet Man Stage - Health Energy 2"
|
||||
magnet_man_c3 = "Magnet Man Stage - Health Energy 3"
|
||||
magnet_man_c4 = "Magnet Man Stage - Health Energy 4"
|
||||
magnet_man_c5 = "Magnet Man Stage - Weapon Energy 1"
|
||||
magnet_man_c6 = "Magnet Man Stage - Weapon Energy 2"
|
||||
magnet_man_c7 = "Magnet Man Stage - Weapon Energy 3"
|
||||
magnet_man_c8 = "Magnet Man Stage - Health Energy 5"
|
||||
gemini_man_c1 = "Gemini Man Stage - 1-Up 1"
|
||||
gemini_man_c2 = "Gemini Man Stage - Health Energy 1"
|
||||
gemini_man_c3 = "Gemini Man Stage - Mystery Tank"
|
||||
gemini_man_c4 = "Gemini Man Stage - Weapon Energy 1"
|
||||
gemini_man_c5 = "Gemini Man Stage - Health Energy 2"
|
||||
gemini_man_c6 = "Gemini Man Stage - 1-Up 2"
|
||||
gemini_man_c7 = "Gemini Man Stage - E-Tank 1"
|
||||
gemini_man_c8 = "Gemini Man Stage - Weapon Energy 2"
|
||||
gemini_man_c9 = "Gemini Man Stage - Weapon Energy 3"
|
||||
gemini_man_c10 = "Gemini Man Stage - E-Tank 2"
|
||||
hard_man_c1 = "Hard Man Stage - Health Energy 1"
|
||||
hard_man_c2 = "Hard Man Stage - Health Energy 2"
|
||||
hard_man_c3 = "Hard Man Stage - E-Tank"
|
||||
hard_man_c4 = "Hard Man Stage - Health Energy 3"
|
||||
hard_man_c5 = "Hard Man Stage - Health Energy 4"
|
||||
hard_man_c6 = "Hard Man Stage - Health Energy 5"
|
||||
hard_man_c7 = "Hard Man Stage - Health Energy 6"
|
||||
top_man_c1 = "Top Man Stage - Health Energy 1"
|
||||
top_man_c2 = "Top Man Stage - Health Energy 2"
|
||||
top_man_c3 = "Top Man Stage - Health Energy 3"
|
||||
top_man_c4 = "Top Man Stage - Health Energy 4"
|
||||
top_man_c5 = "Top Man Stage - Weapon Energy 1"
|
||||
top_man_c6 = "Top Man Stage - 1-Up"
|
||||
top_man_c7 = "Top Man Stage - Health Energy 5"
|
||||
top_man_c8 = "Top Man Stage - Health Energy 6"
|
||||
snake_man_c1 = "Snake Man Stage - Health Energy 1"
|
||||
snake_man_c2 = "Snake Man Stage - Health Energy 2"
|
||||
snake_man_c3 = "Snake Man Stage - Mystery Tank 1"
|
||||
snake_man_c4 = "Snake Man Stage - Mystery Tank 2"
|
||||
snake_man_c5 = "Snake Man Stage - Health Energy 3"
|
||||
spark_man_c1 = "Spark Man Stage - Health Energy 1"
|
||||
spark_man_c2 = "Spark Man Stage - Weapon Energy 1"
|
||||
spark_man_c3 = "Spark Man Stage - Weapon Energy 2"
|
||||
spark_man_c4 = "Spark Man Stage - Weapon Energy 3"
|
||||
spark_man_c5 = "Spark Man Stage - Weapon Energy 4"
|
||||
spark_man_c6 = "Spark Man Stage - Weapon Energy 5"
|
||||
shadow_man_c1 = "Shadow Man Stage - Weapon Energy 1"
|
||||
shadow_man_c2 = "Shadow Man Stage - Weapon Energy 2"
|
||||
shadow_man_c3 = "Shadow Man Stage - Weapon Energy 3"
|
||||
shadow_man_c4 = "Shadow Man Stage - Weapon Energy 4"
|
||||
doc_needle_c1 = "Doc Robot (Needle) - Health Energy 1"
|
||||
doc_needle_c2 = "Doc Robot (Needle) - 1-Up 1"
|
||||
doc_needle_c3 = "Doc Robot (Needle) - E-Tank 1"
|
||||
doc_needle_c4 = "Doc Robot (Needle) - Weapon Energy 1"
|
||||
doc_needle_c5 = "Doc Robot (Needle) - Weapon Energy 2"
|
||||
doc_needle_c6 = "Doc Robot (Needle) - Weapon Energy 3"
|
||||
doc_needle_c7 = "Doc Robot (Needle) - Weapon Energy 4"
|
||||
doc_needle_c8 = "Doc Robot (Needle) - Weapon Energy 5"
|
||||
doc_needle_c9 = "Doc Robot (Needle) - Weapon Energy 6"
|
||||
doc_needle_c10 = "Doc Robot (Needle) - Weapon Energy 7"
|
||||
doc_needle_c11 = "Doc Robot (Needle) - Health Energy 2"
|
||||
doc_gemini_c1 = "Doc Robot (Gemini) - Mystery Tank 1"
|
||||
doc_gemini_c2 = "Doc Robot (Gemini) - Mystery Tank 2"
|
||||
doc_gemini_c3 = "Doc Robot (Gemini) - Weapon Energy 1"
|
||||
doc_gemini_c4 = "Doc Robot (Gemini) - Weapon Energy 2"
|
||||
doc_spark_c1 = "Doc Robot (Spark) - Health Energy 1"
|
||||
doc_spark_c2 = "Doc Robot (Spark) - Health Energy 2"
|
||||
doc_shadow_c1 = "Doc Robot (Shadow) - Health Energy 1"
|
||||
doc_shadow_c2 = "Doc Robot (Shadow) - Weapon Energy 1"
|
||||
doc_shadow_c3 = "Doc Robot (Shadow) - Weapon Energy 2"
|
||||
doc_shadow_c4 = "Doc Robot (Shadow) - Weapon Energy 3"
|
||||
doc_shadow_c5 = "Doc Robot (Shadow) - Weapon Energy 4"
|
||||
wily_1_c1 = "Wily Stage 1 - 1-Up 1"
|
||||
wily_1_c2 = "Wily Stage 1 - E-Tank 1"
|
||||
wily_1_c3 = "Wily Stage 1 - Weapon Energy 1"
|
||||
wily_1_c4 = "Wily Stage 1 - 1-Up 2" # Hard Knuckle
|
||||
wily_1_c5 = "Wily Stage 1 - Health Energy 1" # Hard Knuckle
|
||||
wily_1_c6 = "Wily Stage 1 - Weapon Energy 2" # Hard Knuckle & Rush Vertical
|
||||
wily_1_c7 = "Wily Stage 1 - Health Energy 2" # Hard Knuckle & Rush Vertical
|
||||
wily_1_c8 = "Wily Stage 1 - E-Tank 2" # Hard Knuckle & Rush Vertical
|
||||
wily_1_c9 = "Wily Stage 1 - Health Energy 3"
|
||||
wily_1_c10 = "Wily Stage 1 - Health Energy 4"
|
||||
wily_1_c11 = "Wily Stage 1 - Weapon Energy 3" # Rush Vertical
|
||||
wily_1_c12 = "Wily Stage 1 - Weapon Energy 4" # Rush Vertical
|
||||
wily_2_c1 = "Wily Stage 2 - Weapon Energy 1"
|
||||
wily_2_c2 = "Wily Stage 2 - Weapon Energy 2"
|
||||
wily_2_c3 = "Wily Stage 2 - 1-Up 1"
|
||||
wily_2_c4 = "Wily Stage 2 - Weapon Energy 3"
|
||||
wily_2_c5 = "Wily Stage 2 - Health Energy 1"
|
||||
wily_2_c6 = "Wily Stage 2 - Health Energy 2"
|
||||
wily_2_c7 = "Wily Stage 2 - Health Energy 3"
|
||||
wily_2_c8 = "Wily Stage 2 - Weapon Energy 4"
|
||||
wily_2_c9 = "Wily Stage 2 - E-Tank 1"
|
||||
wily_2_c10 = "Wily Stage 2 - Weapon Energy 5"
|
||||
wily_2_c11 = "Wily Stage 2 - E-Tank 2"
|
||||
wily_2_c12 = "Wily Stage 2 - Weapon Energy 6"
|
||||
wily_2_c13 = "Wily Stage 2 - Weapon Energy 7"
|
||||
wily_3_c1 = "Wily Stage 3 - Weapon Energy 1" # Hard Knuckle
|
||||
wily_3_c2 = "Wily Stage 3 - Weapon Energy 2" # Hard Knuckle
|
||||
wily_3_c3 = "Wily Stage 3 - E-Tank 1"
|
||||
wily_3_c4 = "Wily Stage 3 - 1-Up 1"
|
||||
wily_3_c5 = "Wily Stage 3 - Health Energy 1"
|
||||
wily_3_c6 = "Wily Stage 3 - Health Energy 2"
|
||||
wily_3_c7 = "Wily Stage 3 - Health Energy 3"
|
||||
wily_3_c8 = "Wily Stage 3 - Health Energy 4"
|
||||
wily_3_c9 = "Wily Stage 3 - Weapon Energy 3"
|
||||
wily_3_c10 = "Wily Stage 3 - Mystery Tank 1" # Hard Knuckle
|
||||
wily_3_c11 = "Wily Stage 3 - Mystery Tank 2" # Hard Knuckle
|
||||
wily_4_c1 = "Wily Stage 4 - Weapon Energy 1"
|
||||
wily_4_c2 = "Wily Stage 4 - Weapon Energy 2"
|
||||
wily_4_c3 = "Wily Stage 4 - Weapon Energy 3"
|
||||
wily_4_c4 = "Wily Stage 4 - Weapon Energy 4"
|
||||
wily_4_c5 = "Wily Stage 4 - Weapon Energy 5"
|
||||
wily_4_c6 = "Wily Stage 4 - Health Energy 1"
|
||||
wily_4_c7 = "Wily Stage 4 - Health Energy 2"
|
||||
wily_4_c8 = "Wily Stage 4 - Health Energy 3"
|
||||
wily_4_c9 = "Wily Stage 4 - Health Energy 4"
|
||||
wily_4_c10 = "Wily Stage 4 - Mystery Tank"
|
||||
wily_4_c11 = "Wily Stage 4 - Weapon Energy 6"
|
||||
wily_4_c12 = "Wily Stage 4 - 1-Up"
|
||||
wily_4_c13 = "Wily Stage 4 - Weapon Energy 7"
|
||||
wily_4_c14 = "Wily Stage 4 - Weapon Energy 8"
|
||||
wily_4_c15 = "Wily Stage 4 - Weapon Energy 9"
|
||||
wily_4_c16 = "Wily Stage 4 - Weapon Energy 10"
|
||||
wily_4_c17 = "Wily Stage 4 - Weapon Energy 11"
|
||||
wily_4_c18 = "Wily Stage 4 - Weapon Energy 12"
|
||||
wily_4_c19 = "Wily Stage 4 - Weapon Energy 13"
|
||||
wily_4_c20 = "Wily Stage 4 - Weapon Energy 14"
|
||||
wily_5_c1 = "Wily Stage 5 - Weapon Energy 1"
|
||||
wily_5_c2 = "Wily Stage 5 - Weapon Energy 2"
|
||||
wily_5_c3 = "Wily Stage 5 - Mystery Tank 1"
|
||||
wily_5_c4 = "Wily Stage 5 - Mystery Tank 2"
|
||||
wily_6_c1 = "Wily Stage 6 - Mystery Tank 1"
|
||||
wily_6_c2 = "Wily Stage 6 - Mystery Tank 2"
|
||||
wily_6_c3 = "Wily Stage 6 - Weapon Energy 1"
|
||||
wily_6_c4 = "Wily Stage 6 - Weapon Energy 2"
|
||||
wily_6_c5 = "Wily Stage 6 - 1-Up"
|
||||
wily_6_c6 = "Wily Stage 6 - E-Tank"
|
||||
wily_6_c7 = "Wily Stage 6 - Health Energy"
|
||||
164
worlds/mm3/options.py
Normal file
164
worlds/mm3/options.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from Options import Choice, Toggle, DeathLink, TextChoice, Range, OptionDict, PerGameCommonOptions
|
||||
from schema import Schema, And, Use, Optional
|
||||
from .rules import bosses, weapons_to_id
|
||||
|
||||
|
||||
class EnergyLink(Toggle):
|
||||
"""
|
||||
Enables EnergyLink support.
|
||||
When enabled, pickups dropped from enemies are sent to the EnergyLink pool, and healing/weapon energy/1-Ups can
|
||||
be requested from the EnergyLink pool.
|
||||
Some of the energy sent to the pool will be lost on transfer.
|
||||
"""
|
||||
display_name = "EnergyLink"
|
||||
|
||||
|
||||
class StartingRobotMaster(Choice):
|
||||
"""
|
||||
The initial stage unlocked at the start.
|
||||
"""
|
||||
display_name = "Starting Robot Master"
|
||||
option_needle_man = 0
|
||||
option_magnet_man = 1
|
||||
option_gemini_man = 2
|
||||
option_hard_man = 3
|
||||
option_top_man = 4
|
||||
option_snake_man = 5
|
||||
option_spark_man = 6
|
||||
option_shadow_man = 7
|
||||
default = "random"
|
||||
|
||||
|
||||
class Consumables(Choice):
|
||||
"""
|
||||
When enabled, e-tanks/1-ups/health/weapon energy will be added to the pool of items and included as checks.
|
||||
"""
|
||||
display_name = "Consumables"
|
||||
option_none = 0
|
||||
option_1up_etank = 1
|
||||
option_weapon_health = 2
|
||||
option_all = 3
|
||||
default = 1
|
||||
alias_true = 3
|
||||
alias_false = 0
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: int) -> str:
|
||||
if value == 1:
|
||||
return "1-Ups/E-Tanks"
|
||||
elif value == 2:
|
||||
return "Weapon/Health Energy"
|
||||
return super().get_option_name(value)
|
||||
|
||||
|
||||
class PaletteShuffle(TextChoice):
|
||||
"""
|
||||
Change the color of Mega Man and the Robot Masters.
|
||||
None: The palettes are unchanged.
|
||||
Shuffled: Palette colors are shuffled amongst the robot masters.
|
||||
Randomized: Random (usually good) palettes are generated for each robot master.
|
||||
Singularity: one palette is generated and used for all robot masters.
|
||||
Supports custom palettes using HTML named colors in the
|
||||
following format: Mega Buster-Lavender|Violet;randomized
|
||||
The first value is the character whose palette you'd like to define, then separated by - is a set of 2 colors for
|
||||
that character. separate every color with a pipe, and separate every character as well as the remaining shuffle with
|
||||
a semicolon.
|
||||
"""
|
||||
display_name = "Palette Shuffle"
|
||||
option_none = 0
|
||||
option_shuffled = 1
|
||||
option_randomized = 2
|
||||
option_singularity = 3
|
||||
|
||||
|
||||
class EnemyWeaknesses(Toggle):
|
||||
"""
|
||||
Randomizes the damage dealt to enemies by weapons. Certain enemies will always take damage from the buster.
|
||||
"""
|
||||
display_name = "Random Enemy Weaknesses"
|
||||
|
||||
|
||||
class StrictWeaknesses(Toggle):
|
||||
"""
|
||||
Only your starting Robot Master will take damage from the Mega Buster, the rest must be defeated with weapons.
|
||||
Weapons that only do 1-3 damage to bosses no longer deal damage (aside from Wily/Gamma).
|
||||
"""
|
||||
display_name = "Strict Boss Weaknesses"
|
||||
|
||||
|
||||
class RandomWeaknesses(Choice):
|
||||
"""
|
||||
None: Bosses will have their regular weaknesses.
|
||||
Shuffled: Weapon damage will be shuffled amongst the weapons, so Shadow Blade may do Top Spin damage.
|
||||
Randomized: Weapon damage will be fully randomized.
|
||||
"""
|
||||
display_name = "Random Boss Weaknesses"
|
||||
option_none = 0
|
||||
option_shuffled = 1
|
||||
option_randomized = 2
|
||||
alias_false = 0
|
||||
alias_true = 2
|
||||
|
||||
|
||||
class Wily4Requirement(Range):
|
||||
"""
|
||||
Change the amount of Robot Masters that are required to be defeated for
|
||||
the door to the Wily Machine to open.
|
||||
"""
|
||||
display_name = "Wily 4 Requirement"
|
||||
default = 8
|
||||
range_start = 1
|
||||
range_end = 8
|
||||
|
||||
|
||||
class WeaknessPlando(OptionDict):
|
||||
"""
|
||||
Specify specific damage numbers for boss damage. Can be used even without strict/random weaknesses.
|
||||
plando_weakness:
|
||||
Robot Master:
|
||||
Weapon: Damage
|
||||
"""
|
||||
display_name = "Plando Weaknesses"
|
||||
schema = Schema({
|
||||
Optional(And(str, Use(str.title), lambda s: s in bosses)): {
|
||||
And(str, Use(str.title), lambda s: s in weapons_to_id): And(int, lambda i: i in range(0, 14))
|
||||
}
|
||||
})
|
||||
default = {}
|
||||
|
||||
|
||||
class ReduceFlashing(Toggle):
|
||||
"""
|
||||
Reduce flashing seen in gameplay, such as in stages and when defeating certain bosses.
|
||||
"""
|
||||
display_name = "Reduce Flashing"
|
||||
|
||||
|
||||
class MusicShuffle(Choice):
|
||||
"""
|
||||
Shuffle the music that plays in every stage
|
||||
"""
|
||||
display_name = "Music Shuffle"
|
||||
option_none = 0
|
||||
option_shuffled = 1
|
||||
option_randomized = 2
|
||||
option_no_music = 3
|
||||
default = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MM3Options(PerGameCommonOptions):
|
||||
death_link: DeathLink
|
||||
energy_link: EnergyLink
|
||||
starting_robot_master: StartingRobotMaster
|
||||
consumables: Consumables
|
||||
enemy_weakness: EnemyWeaknesses
|
||||
strict_weakness: StrictWeaknesses
|
||||
random_weakness: RandomWeaknesses
|
||||
wily_4_requirement: Wily4Requirement
|
||||
plando_weakness: WeaknessPlando
|
||||
palette_shuffle: PaletteShuffle
|
||||
reduce_flashing: ReduceFlashing
|
||||
music_shuffle: MusicShuffle
|
||||
374
worlds/mm3/rom.py
Normal file
374
worlds/mm3/rom.py
Normal file
@@ -0,0 +1,374 @@
|
||||
import pkgutil
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
import hashlib
|
||||
import Utils
|
||||
import os
|
||||
|
||||
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes
|
||||
from . import names
|
||||
from .rules import bosses
|
||||
|
||||
from .text import MM3TextEntry
|
||||
from .color import get_colors_for_item, write_palette_shuffle
|
||||
from .options import Consumables
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MM3World
|
||||
|
||||
MM3LCHASH = "5266687de215e790b2008284402f3917"
|
||||
PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61"
|
||||
MM3NESHASH = "4a53b6f58067d62c9a43404fe835dd5c"
|
||||
MM3VCHASH = "c50008f1ac86fae8d083232cdd3001a5"
|
||||
|
||||
enemy_weakness_ptrs: dict[int, int] = {
|
||||
0: 0x14100,
|
||||
1: 0x14200,
|
||||
2: 0x14300,
|
||||
3: 0x14400,
|
||||
4: 0x14500,
|
||||
5: 0x14600,
|
||||
6: 0x14700,
|
||||
7: 0x14800,
|
||||
8: 0x14900,
|
||||
}
|
||||
|
||||
enemy_addresses: dict[str, int] = {
|
||||
"Dada": 0x12,
|
||||
"Potton": 0x13,
|
||||
"New Shotman": 0x15,
|
||||
"Hammer Joe": 0x16,
|
||||
"Peterchy": 0x17,
|
||||
"Bubukan": 0x18,
|
||||
"Vault Pole": 0x19, # Capcom..., why did you name an enemy Pole?
|
||||
"Bomb Flier": 0x1A,
|
||||
"Yambow": 0x1D,
|
||||
"Metall 2": 0x1E,
|
||||
"Cannon": 0x22,
|
||||
"Jamacy": 0x25,
|
||||
"Jamacy 2": 0x26, # dunno what this is, but I won't question
|
||||
"Jamacy 3": 0x27,
|
||||
"Jamacy 4": 0x28, # tf is this Capcom
|
||||
"Mag Fly": 0x2A,
|
||||
"Egg": 0x2D,
|
||||
"Gyoraibo 2": 0x2E,
|
||||
"Junk Golem": 0x2F,
|
||||
"Pickelman Bull": 0x30,
|
||||
"Nitron": 0x35,
|
||||
"Pole": 0x37,
|
||||
"Gyoraibo": 0x38,
|
||||
"Hari Harry": 0x3A,
|
||||
"Penpen Maker": 0x3B,
|
||||
"Returning Monking": 0x3C,
|
||||
"Have 'Su' Bee": 0x3E,
|
||||
"Hive": 0x3F,
|
||||
"Bolton-Nutton": 0x40,
|
||||
"Walking Bomb": 0x44,
|
||||
"Elec'n": 0x45,
|
||||
"Mechakkero": 0x47,
|
||||
"Chibee": 0x4B,
|
||||
"Swimming Penpen": 0x4D,
|
||||
"Top": 0x52,
|
||||
"Penpen": 0x56,
|
||||
"Komasaburo": 0x57,
|
||||
"Parasyu": 0x59,
|
||||
"Hologran (Static)": 0x5A,
|
||||
"Hologran (Moving)": 0x5B,
|
||||
"Bomber Pepe": 0x5C,
|
||||
"Metall DX": 0x5D,
|
||||
"Petit Snakey": 0x5E,
|
||||
"Proto Man": 0x62,
|
||||
"Break Man": 0x63,
|
||||
"Metall": 0x7D,
|
||||
"Giant Springer": 0x83,
|
||||
"Springer Missile": 0x85,
|
||||
"Giant Snakey": 0x99,
|
||||
"Tama": 0x9A,
|
||||
"Doc Robot (Flash)": 0xB0,
|
||||
"Doc Robot (Wood)": 0xB1,
|
||||
"Doc Robot (Crash)": 0xB2,
|
||||
"Doc Robot (Metal)": 0xB3,
|
||||
"Doc Robot (Bubble)": 0xC0,
|
||||
"Doc Robot (Heat)": 0xC1,
|
||||
"Doc Robot (Quick)": 0xC2,
|
||||
"Doc Robot (Air)": 0xC3,
|
||||
"Snake": 0xCA,
|
||||
"Needle Man": 0xD0,
|
||||
"Magnet Man": 0xD1,
|
||||
"Top Man": 0xD2,
|
||||
"Shadow Man": 0xD3,
|
||||
"Top Man's Top": 0xD5,
|
||||
"Shadow Man (Sliding)": 0xD8, # Capcom I swear
|
||||
"Hard Man": 0xE0,
|
||||
"Spark Man": 0xE2,
|
||||
"Snake Man": 0xE4,
|
||||
"Gemini Man": 0xE6,
|
||||
"Gemini Man (Clone)": 0xE7, # Capcom why
|
||||
"Yellow Devil MK-II": 0xF1,
|
||||
"Wily Machine 3": 0xF3,
|
||||
"Gamma": 0xF8,
|
||||
"Kamegoro": 0x101,
|
||||
"Kamegoro Shell": 0x102,
|
||||
"Holograph Mega Man": 0x105,
|
||||
"Giant Metall": 0x10C, # This is technically FC but we're +16 from the rom header
|
||||
}
|
||||
|
||||
# addresses printed when assembling basepatch
|
||||
wily_4_ptr: int = 0x7F570
|
||||
consumables_ptr: int = 0x7FDEA
|
||||
energylink_ptr: int = 0x7FDF9
|
||||
|
||||
|
||||
class MM3ProcedurePatch(APProcedurePatch, APTokenMixin):
|
||||
hash = [MM3LCHASH, MM3NESHASH, MM3VCHASH]
|
||||
game = "Mega Man 3"
|
||||
patch_file_ending = ".apmm3"
|
||||
result_file_ending = ".nes"
|
||||
name: bytearray
|
||||
procedure = [
|
||||
("apply_bsdiff4", ["mm3_basepatch.bsdiff4"]),
|
||||
("apply_tokens", ["token_patch.bin"]),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
return get_base_rom_bytes()
|
||||
|
||||
def write_byte(self, offset: int, value: int) -> None:
|
||||
self.write_token(APTokenTypes.WRITE, offset, value.to_bytes(1, "little"))
|
||||
|
||||
def write_bytes(self, offset: int, value: Iterable[int]) -> None:
|
||||
self.write_token(APTokenTypes.WRITE, offset, bytes(value))
|
||||
|
||||
|
||||
def patch_rom(world: "MM3World", patch: MM3ProcedurePatch) -> None:
|
||||
patch.write_file("mm3_basepatch.bsdiff4", pkgutil.get_data(__name__, os.path.join("data", "mm3_basepatch.bsdiff4")))
|
||||
# text writing
|
||||
|
||||
base_address = 0x3C000
|
||||
color_address = 0x31BC7
|
||||
for i, offset, location in zip([0, 8, 1, 2,
|
||||
3, 4, 5, 6,
|
||||
7, 9],
|
||||
[0x10, 0x50, 0x91, 0xD2,
|
||||
0x113, 0x154, 0x195, 0x1D6,
|
||||
0x217, 0x257],
|
||||
[
|
||||
names.get_needle_cannon,
|
||||
names.get_rush_jet,
|
||||
names.get_magnet_missile,
|
||||
names.get_gemini_laser,
|
||||
names.get_hard_knuckle,
|
||||
names.get_top_spin,
|
||||
names.get_search_snake,
|
||||
names.get_spark_shock,
|
||||
names.get_shadow_blade,
|
||||
names.get_rush_marine,
|
||||
]):
|
||||
item = world.get_location(location).item
|
||||
if item:
|
||||
if len(item.name) <= 13:
|
||||
# we want to just place it in the center
|
||||
first_str = ""
|
||||
second_str = item.name
|
||||
third_str = ""
|
||||
elif len(item.name) <= 26:
|
||||
# spread across second and third
|
||||
first_str = ""
|
||||
second_str = item.name[:13]
|
||||
third_str = item.name[13:]
|
||||
else:
|
||||
# all three
|
||||
first_str = item.name[:13]
|
||||
second_str = item.name[13:26]
|
||||
third_str = item.name[26:]
|
||||
if len(third_str) > 13:
|
||||
third_str = third_str[:13]
|
||||
player_str = world.multiworld.get_player_name(item.player)
|
||||
if len(player_str) > 13:
|
||||
player_str = player_str[:13]
|
||||
y_coords = 0xA5
|
||||
row = 0x21
|
||||
if location in [names.get_rush_marine, names.get_rush_jet]:
|
||||
y_coords = 0x45
|
||||
row = 0x22
|
||||
patch.write_bytes(base_address + offset, MM3TextEntry(first_str, y_coords, row).resolve())
|
||||
patch.write_bytes(base_address + 16 + offset, MM3TextEntry(second_str, y_coords + 0x20, row).resolve())
|
||||
patch.write_bytes(base_address + 32 + offset, MM3TextEntry(third_str, y_coords + 0x40, row).resolve())
|
||||
if y_coords + 0x60 > 0xFF:
|
||||
row += 1
|
||||
y_coords = 0x01
|
||||
patch.write_bytes(base_address + 48 + offset, MM3TextEntry(player_str, y_coords, row).resolve())
|
||||
colors_high, colors_low = get_colors_for_item(item.name)
|
||||
patch.write_bytes(color_address + (i * 8) + 1, colors_high)
|
||||
patch.write_bytes(color_address + (i * 8) + 5, colors_low)
|
||||
else:
|
||||
patch.write_bytes(base_address + 48 + offset, MM3TextEntry(player_str, y_coords + 0x60, row).resolve())
|
||||
|
||||
write_palette_shuffle(world, patch)
|
||||
|
||||
enemy_weaknesses: dict[str, dict[int, int]] = {}
|
||||
|
||||
if world.options.strict_weakness or world.options.random_weakness or world.options.plando_weakness:
|
||||
# we need to write boss weaknesses
|
||||
for boss in bosses:
|
||||
if boss == "Kamegoro Maker":
|
||||
enemy_weaknesses["Kamegoro"] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage}
|
||||
enemy_weaknesses["Kamegoro Shell"] = {i: world.weapon_damage[i][bosses[boss]]
|
||||
for i in world.weapon_damage}
|
||||
elif boss == "Gemini Man":
|
||||
enemy_weaknesses[boss] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage}
|
||||
enemy_weaknesses["Gemini Man (Clone)"] = {i: world.weapon_damage[i][bosses[boss]]
|
||||
for i in world.weapon_damage}
|
||||
elif boss == "Shadow Man":
|
||||
enemy_weaknesses[boss] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage}
|
||||
enemy_weaknesses["Shadow Man (Sliding)"] = {i: world.weapon_damage[i][bosses[boss]]
|
||||
for i in world.weapon_damage}
|
||||
else:
|
||||
enemy_weaknesses[boss] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage}
|
||||
|
||||
if world.options.enemy_weakness:
|
||||
for enemy in enemy_addresses:
|
||||
if enemy in [*bosses.keys(), "Kamegoro", "Kamegoro Shell", "Gemini Man (Clone)", "Shadow Man (Sliding)"]:
|
||||
continue
|
||||
enemy_weaknesses[enemy] = {weapon: world.random.randint(-4, 4) for weapon in enemy_weakness_ptrs}
|
||||
if enemy in ["Tama", "Giant Snakey", "Proto Man", "Giant Metall"] and enemy_weaknesses[enemy][0] <= 0:
|
||||
enemy_weaknesses[enemy][0] = 1
|
||||
elif enemy == "Jamacy 2":
|
||||
# bruh
|
||||
if not enemy_weaknesses[enemy][8] > 0:
|
||||
enemy_weaknesses[enemy][8] = 1
|
||||
if not enemy_weaknesses[enemy][3] > 0:
|
||||
enemy_weaknesses[enemy][3] = 1
|
||||
|
||||
for enemy, damage in enemy_weaknesses.items():
|
||||
for weapon in enemy_weakness_ptrs:
|
||||
if damage[weapon] < 0:
|
||||
damage[weapon] = 256 + damage[weapon]
|
||||
patch.write_byte(enemy_weakness_ptrs[weapon] + enemy_addresses[enemy], damage[weapon])
|
||||
|
||||
if world.options.consumables != Consumables.option_all:
|
||||
value_a = 0x64
|
||||
value_b = 0x6A
|
||||
if world.options.consumables in (Consumables.option_none, Consumables.option_1up_etank):
|
||||
value_a = 0x68
|
||||
if world.options.consumables in (Consumables.option_none, Consumables.option_weapon_health):
|
||||
value_b = 0x67
|
||||
patch.write_byte(consumables_ptr - 3, value_a)
|
||||
patch.write_byte(consumables_ptr + 1, value_b)
|
||||
|
||||
patch.write_byte(wily_4_ptr + 1, world.options.wily_4_requirement.value)
|
||||
|
||||
patch.write_byte(energylink_ptr + 1, world.options.energy_link.value)
|
||||
|
||||
if world.options.reduce_flashing:
|
||||
# Spark Man
|
||||
patch.write_byte(0x12649, 8)
|
||||
patch.write_byte(0x1264E, 8)
|
||||
patch.write_byte(0x12653, 8)
|
||||
# Shadow Man
|
||||
patch.write_byte(0x12658, 0x10)
|
||||
# Gemini Man
|
||||
patch.write_byte(0x12637, 0x20)
|
||||
patch.write_byte(0x1263D, 0x20)
|
||||
patch.write_byte(0x12643, 0x20)
|
||||
# Gamma
|
||||
patch.write_byte(0x7DA4A, 0xF)
|
||||
|
||||
if world.options.music_shuffle:
|
||||
if world.options.music_shuffle.current_key == "no_music":
|
||||
pool = [0xF0] * 18
|
||||
elif world.options.music_shuffle.current_key == "randomized":
|
||||
pool = world.random.choices(range(1, 0xC), k=18)
|
||||
else:
|
||||
pool = [1, 2, 3, 4, 5, 6, 7, 8, 1, 3, 7, 8, 9, 9, 10, 10, 11, 11]
|
||||
world.random.shuffle(pool)
|
||||
patch.write_bytes(0x7CD1C, pool)
|
||||
|
||||
from Utils import __version__
|
||||
patch.name = bytearray(f'MM3{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0',
|
||||
'utf8')[:21]
|
||||
patch.name.extend([0] * (21 - len(patch.name)))
|
||||
patch.write_bytes(0x3F330, patch.name) # We changed this section, but this pointer is still valid!
|
||||
deathlink_byte = world.options.death_link.value | (world.options.energy_link.value << 1)
|
||||
patch.write_byte(0x3F346, deathlink_byte)
|
||||
|
||||
patch.write_bytes(0x3F34C, world.world_version)
|
||||
|
||||
version_map = {
|
||||
"0": 0x00,
|
||||
"1": 0x01,
|
||||
"2": 0x02,
|
||||
"3": 0x03,
|
||||
"4": 0x04,
|
||||
"5": 0x05,
|
||||
"6": 0x06,
|
||||
"7": 0x07,
|
||||
"8": 0x08,
|
||||
"9": 0x09,
|
||||
".": 0x26
|
||||
}
|
||||
patch.write_token(APTokenTypes.RLE, 0x653B, (11, 0x25))
|
||||
patch.write_token(APTokenTypes.RLE, 0x6549, (25, 0x25))
|
||||
|
||||
# BY SILVRIS
|
||||
patch.write_bytes(0x653B, [0x0B, 0x22, 0x25, 0x1C, 0x12, 0x15, 0x1F, 0x1B, 0x12, 0x1C])
|
||||
# ARCHIPELAGO x.x.x
|
||||
patch.write_bytes(0x654D,
|
||||
[0x0A, 0x1B, 0x0C, 0x11, 0x12, 0x19, 0x0E, 0x15, 0x0A, 0x10, 0x18])
|
||||
patch.write_bytes(0x6559, list(map(lambda c: version_map[c], __version__)))
|
||||
|
||||
patch.write_file("token_patch.bin", patch.get_token_binary())
|
||||
|
||||
|
||||
header = b"\x4E\x45\x53\x1A\x10\x10\x40\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def read_headerless_nes_rom(rom: bytes) -> bytes:
|
||||
if rom[:4] == b"NES\x1A":
|
||||
return rom[16:]
|
||||
else:
|
||||
return rom
|
||||
|
||||
|
||||
def get_base_rom_bytes(file_name: str = "") -> bytes:
|
||||
base_rom_bytes: bytes | None = getattr(get_base_rom_bytes, "base_rom_bytes", None)
|
||||
if not base_rom_bytes:
|
||||
file_name = get_base_rom_path(file_name)
|
||||
base_rom_bytes = read_headerless_nes_rom(bytes(open(file_name, "rb").read()))
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_rom_bytes)
|
||||
if basemd5.hexdigest() == PROTEUSHASH:
|
||||
base_rom_bytes = extract_mm3(base_rom_bytes)
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_rom_bytes)
|
||||
if basemd5.hexdigest() not in {MM3LCHASH, MM3NESHASH, MM3VCHASH}:
|
||||
print(basemd5.hexdigest())
|
||||
raise Exception("Supplied Base Rom does not match known MD5 for US, LC, or US VC release. "
|
||||
"Get the correct game and version, then dump it")
|
||||
headered_rom = bytearray(base_rom_bytes)
|
||||
headered_rom[0:0] = header
|
||||
setattr(get_base_rom_bytes, "base_rom_bytes", bytes(headered_rom))
|
||||
return bytes(headered_rom)
|
||||
return base_rom_bytes
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: str = "") -> str:
|
||||
from . import MM3World
|
||||
if not file_name:
|
||||
file_name = MM3World.settings.rom_file
|
||||
if not os.path.exists(file_name):
|
||||
file_name = Utils.user_path(file_name)
|
||||
return file_name
|
||||
|
||||
|
||||
prg_offset = 0xCF1B0
|
||||
prg_size = 0x40000
|
||||
chr_offset = 0x10F1B0
|
||||
chr_size = 0x20000
|
||||
|
||||
|
||||
def extract_mm3(proteus: bytes) -> bytes:
|
||||
mm3 = bytearray(proteus[prg_offset:prg_offset + prg_size])
|
||||
mm3.extend(proteus[chr_offset:chr_offset + chr_size])
|
||||
return bytes(mm3)
|
||||
388
worlds/mm3/rules.py
Normal file
388
worlds/mm3/rules.py
Normal file
@@ -0,0 +1,388 @@
|
||||
from math import ceil
|
||||
from typing import TYPE_CHECKING
|
||||
from . import names
|
||||
from .locations import get_boss_locations, get_oneup_locations, get_energy_locations
|
||||
from worlds.generic.Rules import add_rule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MM3World
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
bosses: dict[str, int] = {
|
||||
"Needle Man": 0,
|
||||
"Magnet Man": 1,
|
||||
"Gemini Man": 2,
|
||||
"Hard Man": 3,
|
||||
"Top Man": 4,
|
||||
"Snake Man": 5,
|
||||
"Spark Man": 6,
|
||||
"Shadow Man": 7,
|
||||
"Doc Robot (Metal)": 8,
|
||||
"Doc Robot (Quick)": 9,
|
||||
"Doc Robot (Air)": 10,
|
||||
"Doc Robot (Crash)": 11,
|
||||
"Doc Robot (Flash)": 12,
|
||||
"Doc Robot (Bubble)": 13,
|
||||
"Doc Robot (Wood)": 14,
|
||||
"Doc Robot (Heat)": 15,
|
||||
"Break Man": 16,
|
||||
"Kamegoro Maker": 17,
|
||||
"Yellow Devil MK-II": 18,
|
||||
"Holograph Mega Man": 19,
|
||||
"Wily Machine 3": 20,
|
||||
"Gamma": 21
|
||||
}
|
||||
|
||||
weapons_to_id: dict[str, int] = {
|
||||
"Mega Buster": 0,
|
||||
"Needle Cannon": 1,
|
||||
"Magnet Missile": 2,
|
||||
"Gemini Laser": 3,
|
||||
"Hard Knuckle": 4,
|
||||
"Top Spin": 5,
|
||||
"Search Snake": 6,
|
||||
"Spark Shot": 7,
|
||||
"Shadow Blade": 8,
|
||||
}
|
||||
|
||||
weapon_damage: dict[int, list[int]] = {
|
||||
0: [1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 3, 1, 1, 1, 0, ], # Mega Buster
|
||||
1: [4, 1, 1, 0, 2, 4, 2, 1, 0, 1, 1, 2, 4, 2, 4, 2, 0, 3, 1, 1, 1, 0, ], # Needle Cannon
|
||||
2: [1, 4, 2, 4, 1, 0, 0, 1, 4, 2, 4, 1, 1, 0, 0, 1, 0, 3, 1, 0, 1, 0, ], # Magnet Missile
|
||||
3: [7, 2, 4, 1, 0, 1, 1, 1, 1, 4, 2, 0, 4, 1, 1, 1, 0, 3, 1, 1, 1, 0, ], # Gemini Laser
|
||||
4: [0, 2, 2, 4, 7, 2, 2, 2, 4, 1, 2, 7, 0, 2, 2, 2, 0, 1, 5, 4, 7, 4, ], # Hard Knuckle
|
||||
5: [1, 1, 2, 0, 4, 2, 1, 7, 0, 1, 1, 4, 1, 1, 2, 7, 0, 1, 0, 7, 0, 2, ], # Top Spin
|
||||
6: [1, 1, 5, 0, 1, 4, 0, 1, 0, 4, 1, 1, 1, 0, 4, 1, 0, 1, 0, 7, 4, 2, ], # Search Snake
|
||||
7: [0, 7, 1, 0, 1, 1, 4, 1, 2, 1, 4, 1, 0, 4, 1, 1, 0, 0, 0, 0, 7, 0, ], # Spark Shot
|
||||
8: [2, 7, 2, 0, 1, 2, 4, 4, 2, 2, 0, 1, 2, 4, 2, 4, 0, 1, 3, 2, 2, 2, ], # Shadow Blade
|
||||
}
|
||||
|
||||
weapons_to_name: dict[int, str] = {
|
||||
1: names.needle_cannon,
|
||||
2: names.magnet_missile,
|
||||
3: names.gemini_laser,
|
||||
4: names.hard_knuckle,
|
||||
5: names.top_spin,
|
||||
6: names.search_snake,
|
||||
7: names.spark_shock,
|
||||
8: names.shadow_blade
|
||||
}
|
||||
|
||||
minimum_weakness_requirement: dict[int, int] = {
|
||||
0: 1, # Mega Buster is free
|
||||
1: 1, # 112 shots of Needle Cannon
|
||||
2: 2, # 14 shots of Magnet Missile
|
||||
3: 2, # 14 shots of Gemini Laser
|
||||
4: 2, # 14 uses of Hard Knuckle
|
||||
5: 4, # an unknown amount of Top Spin (4 means you should be able to be fine)
|
||||
6: 1, # 56 uses of Search Snake
|
||||
7: 2, # 14 functional uses of Spark Shot (fires in twos)
|
||||
8: 1, # 56 uses of Shadow Blade
|
||||
}
|
||||
|
||||
robot_masters: dict[int, str] = {
|
||||
0: "Needle Man Defeated",
|
||||
1: "Magnet Man Defeated",
|
||||
2: "Gemini Man Defeated",
|
||||
3: "Hard Man Defeated",
|
||||
4: "Top Man Defeated",
|
||||
5: "Snake Man Defeated",
|
||||
6: "Spark Man Defeated",
|
||||
7: "Shadow Man Defeated"
|
||||
}
|
||||
|
||||
weapon_costs = {
|
||||
0: 0,
|
||||
1: 0.25,
|
||||
2: 2,
|
||||
3: 2,
|
||||
4: 2,
|
||||
5: 7, # Not really, but we can really only rely on Top for one RBM
|
||||
6: 0.5,
|
||||
7: 2,
|
||||
8: 0.5,
|
||||
}
|
||||
|
||||
|
||||
def can_defeat_enough_rbms(state: "CollectionState", player: int,
|
||||
required: int, boss_requirements: dict[int, list[int]]) -> bool:
|
||||
can_defeat = 0
|
||||
for boss, reqs in boss_requirements.items():
|
||||
if boss in robot_masters:
|
||||
if state.has_all(map(lambda x: weapons_to_name[x], reqs), player):
|
||||
can_defeat += 1
|
||||
if can_defeat >= required:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_rush_vertical(state: "CollectionState", player: int) -> bool:
|
||||
return state.has_any([names.rush_coil, names.rush_jet], player)
|
||||
|
||||
|
||||
def can_traverse_long_water(state: "CollectionState", player: int) -> bool:
|
||||
return state.has_any([names.rush_marine, names.rush_jet], player)
|
||||
|
||||
|
||||
def has_any_rush(state: "CollectionState", player: int) -> bool:
|
||||
return state.has_any([names.rush_coil, names.rush_jet, names.rush_marine], player)
|
||||
|
||||
|
||||
def has_rush_jet(state: "CollectionState", player: int) -> bool:
|
||||
return state.has(names.rush_jet, player)
|
||||
|
||||
|
||||
def set_rules(world: "MM3World") -> None:
|
||||
# most rules are set on region, so we only worry about rules required within stage access
|
||||
# or rules variable on settings
|
||||
if hasattr(world.multiworld, "re_gen_passthrough"):
|
||||
slot_data = getattr(world.multiworld, "re_gen_passthrough")["Mega Man 3"]
|
||||
world.weapon_damage = slot_data["weapon_damage"]
|
||||
else:
|
||||
if world.options.random_weakness == world.options.random_weakness.option_shuffled:
|
||||
weapon_tables = [table.copy() for weapon, table in weapon_damage.items() if weapon != 0]
|
||||
world.random.shuffle(weapon_tables)
|
||||
for i in range(1, 9):
|
||||
world.weapon_damage[i] = weapon_tables.pop()
|
||||
elif world.options.random_weakness == world.options.random_weakness.option_randomized:
|
||||
world.weapon_damage = {i: [] for i in range(9)}
|
||||
for boss in range(22):
|
||||
for weapon in world.weapon_damage:
|
||||
world.weapon_damage[weapon].append(min(14, max(0, int(world.random.normalvariate(3, 3)))))
|
||||
if not any([world.weapon_damage[weapon][boss] >= 4
|
||||
for weapon in range(1, 9)]):
|
||||
# failsafe, there should be at least one defined non-Buster weakness
|
||||
weapon = world.random.randint(1, 7)
|
||||
world.weapon_damage[weapon][boss] = world.random.randint(4, 14) # Force weakness
|
||||
# handle Break Man
|
||||
boss = 16
|
||||
for weapon in world.weapon_damage:
|
||||
world.weapon_damage[weapon][boss] = 0
|
||||
weapon = world.random.choice(list(world.weapon_damage.keys()))
|
||||
world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon]
|
||||
|
||||
if world.options.strict_weakness:
|
||||
for weapon in weapon_damage:
|
||||
for i in range(22):
|
||||
if i == 16:
|
||||
continue # Break is only weak to buster on non-random, and minimal damage on random
|
||||
elif weapon == 0:
|
||||
world.weapon_damage[weapon][i] = 0
|
||||
elif i in (20, 21) and not world.options.random_weakness:
|
||||
continue
|
||||
# Gamma and Wily Machine need all weaknesses present, so allow
|
||||
elif not world.options.random_weakness == world.options.random_weakness.option_randomized \
|
||||
and i == 17:
|
||||
if 3 > world.weapon_damage[weapon][i] > 0:
|
||||
# Kamegoros take 3 max from weapons on non-random
|
||||
world.weapon_damage[weapon][i] = 0
|
||||
elif 4 > world.weapon_damage[weapon][i] > 0:
|
||||
world.weapon_damage[weapon][i] = 0
|
||||
|
||||
for p_boss in world.options.plando_weakness:
|
||||
for p_weapon in world.options.plando_weakness[p_boss]:
|
||||
if not any(w for w in world.weapon_damage
|
||||
if w != weapons_to_id[p_weapon]
|
||||
and world.weapon_damage[w][bosses[p_boss]] > minimum_weakness_requirement[w]):
|
||||
# we need to replace this weakness
|
||||
weakness = world.random.choice([key for key in world.weapon_damage
|
||||
if key != weapons_to_id[p_weapon]])
|
||||
world.weapon_damage[weakness][bosses[p_boss]] = minimum_weakness_requirement[weakness]
|
||||
world.weapon_damage[weapons_to_id[p_weapon]][bosses[p_boss]] \
|
||||
= world.options.plando_weakness[p_boss][p_weapon]
|
||||
|
||||
# handle special cases
|
||||
for boss in range(22):
|
||||
for weapon in range(1, 9):
|
||||
if (0 < world.weapon_damage[weapon][boss] < minimum_weakness_requirement[weapon] and
|
||||
not any(world.weapon_damage[i][boss] >= minimum_weakness_requirement[weapon]
|
||||
for i in range(1, 8) if i != weapon)):
|
||||
world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon]
|
||||
|
||||
if world.weapon_damage[0][world.options.starting_robot_master.value] < 1:
|
||||
world.weapon_damage[0][world.options.starting_robot_master.value] = 1
|
||||
|
||||
# weakness validation, it is better to confirm a completable seed than respect plando
|
||||
boss_health = {boss: 0x1C for boss in range(8)}
|
||||
|
||||
weapon_energy = {key: float(0x1C) for key in weapon_costs}
|
||||
weapon_boss = {boss: {weapon: world.weapon_damage[weapon][boss] for weapon in world.weapon_damage}
|
||||
for boss in range(8)}
|
||||
flexibility = {
|
||||
boss: (
|
||||
sum(damage_value > 0 for damage_value in
|
||||
weapon_damages.values()) # Amount of weapons that hit this boss
|
||||
* sum(weapon_damages.values()) # Overall damage that those weapons do
|
||||
)
|
||||
for boss, weapon_damages in weapon_boss.items()
|
||||
}
|
||||
boss_flexibility = sorted(flexibility, key=flexibility.get) # Fast way to sort dict by value
|
||||
used_weapons: dict[int, set[int]] = {i: set() for i in range(8)}
|
||||
for boss in boss_flexibility:
|
||||
boss_damage = weapon_boss[boss]
|
||||
weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in
|
||||
boss_damage.items() if weapon_energy[weapon] > 0}
|
||||
while boss_health[boss] > 0:
|
||||
if boss_damage[0] > 0:
|
||||
boss_health[boss] = 0 # if we can buster, we should buster
|
||||
continue
|
||||
highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys()))
|
||||
uses = weapon_energy[wp] // weapon_costs[wp]
|
||||
if int(uses * boss_damage[wp]) >= boss_health[boss]:
|
||||
used = ceil(boss_health[boss] / boss_damage[wp])
|
||||
weapon_energy[wp] -= weapon_costs[wp] * used
|
||||
boss_health[boss] = 0
|
||||
used_weapons[boss].add(wp)
|
||||
elif highest <= 0:
|
||||
# we are out of weapons that can actually damage the boss
|
||||
# so find the weapon that has the most uses, and apply that as an additional weakness
|
||||
# it should be impossible to be out of energy
|
||||
max_uses, wp = max((weapon_energy[weapon] // weapon_costs[weapon], weapon)
|
||||
for weapon in weapon_weight
|
||||
if weapon != 0)
|
||||
world.weapon_damage[wp][boss] = minimum_weakness_requirement[wp]
|
||||
used = min(int(weapon_energy[wp] // weapon_costs[wp]),
|
||||
ceil(boss_health[boss] / minimum_weakness_requirement[wp]))
|
||||
weapon_energy[wp] -= weapon_costs[wp] * used
|
||||
boss_health[boss] -= int(used * minimum_weakness_requirement[wp])
|
||||
weapon_weight.pop(wp)
|
||||
used_weapons[boss].add(wp)
|
||||
else:
|
||||
# drain the weapon and continue
|
||||
boss_health[boss] -= int(uses * boss_damage[wp])
|
||||
weapon_energy[wp] -= weapon_costs[wp] * uses
|
||||
weapon_weight.pop(wp)
|
||||
used_weapons[boss].add(wp)
|
||||
|
||||
world.wily_4_weapons = {boss: sorted(weapons) for boss, weapons in used_weapons.items()}
|
||||
|
||||
for i, boss_locations in zip(range(22), [
|
||||
get_boss_locations("Needle Man Stage"),
|
||||
get_boss_locations("Magnet Man Stage"),
|
||||
get_boss_locations("Gemini Man Stage"),
|
||||
get_boss_locations("Hard Man Stage"),
|
||||
get_boss_locations("Top Man Stage"),
|
||||
get_boss_locations("Snake Man Stage"),
|
||||
get_boss_locations("Spark Man Stage"),
|
||||
get_boss_locations("Shadow Man Stage"),
|
||||
get_boss_locations("Doc Robot (Spark) - Metal"),
|
||||
get_boss_locations("Doc Robot (Spark) - Quick"),
|
||||
get_boss_locations("Doc Robot (Needle) - Air"),
|
||||
get_boss_locations("Doc Robot (Needle) - Crash"),
|
||||
get_boss_locations("Doc Robot (Gemini) - Flash"),
|
||||
get_boss_locations("Doc Robot (Gemini) - Bubble"),
|
||||
get_boss_locations("Doc Robot (Shadow) - Wood"),
|
||||
get_boss_locations("Doc Robot (Shadow) - Heat"),
|
||||
get_boss_locations("Break Man"),
|
||||
get_boss_locations("Wily Stage 1"),
|
||||
get_boss_locations("Wily Stage 2"),
|
||||
get_boss_locations("Wily Stage 3"),
|
||||
get_boss_locations("Wily Stage 5"),
|
||||
get_boss_locations("Wily Stage 6")
|
||||
]):
|
||||
if world.weapon_damage[0][i] > 0:
|
||||
continue # this can always be in logic
|
||||
weapons = []
|
||||
for weapon in range(1, 9):
|
||||
if world.weapon_damage[weapon][i] > 0:
|
||||
if world.weapon_damage[weapon][i] < minimum_weakness_requirement[weapon]:
|
||||
continue
|
||||
weapons.append(weapons_to_name[weapon])
|
||||
if not weapons:
|
||||
raise Exception(f"Attempted to have boss {i} with no weakness! Seed: {world.multiworld.seed}")
|
||||
for location in boss_locations:
|
||||
if i in (20, 21):
|
||||
# multi-phase fights, get all potential weaknesses
|
||||
# we should probably do this smarter, but this works for now
|
||||
add_rule(world.get_location(location),
|
||||
lambda state, weps=tuple(weapons): state.has_all(weps, world.player))
|
||||
else:
|
||||
add_rule(world.get_location(location),
|
||||
lambda state, weps=tuple(weapons): state.has_any(weps, world.player))
|
||||
|
||||
# Need to defeat x amount of robot masters for Wily 4
|
||||
add_rule(world.get_location(names.wily_stage_4),
|
||||
lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_4_requirement.value,
|
||||
world.wily_4_weapons))
|
||||
|
||||
# Handle Doc Robo stage connections
|
||||
for entrance, location in (("To Doc Robot (Needle) - Crash", names.doc_air),
|
||||
("To Doc Robot (Gemini) - Bubble", names.doc_flash),
|
||||
("To Doc Robot (Shadow) - Heat", names.doc_wood),
|
||||
("To Doc Robot (Spark) - Quick", names.doc_metal)):
|
||||
entrance_object = world.get_entrance(entrance)
|
||||
add_rule(entrance_object, lambda state, loc=location: state.can_reach(loc, "Location", world.player))
|
||||
|
||||
# finally, real logic
|
||||
for location in get_boss_locations("Hard Man Stage"):
|
||||
add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player))
|
||||
|
||||
for location in get_boss_locations("Gemini Man Stage"):
|
||||
add_rule(world.get_location(location), lambda state: has_any_rush(state, world.player))
|
||||
|
||||
add_rule(world.get_entrance("To Doc Robot (Spark) - Metal"),
|
||||
lambda state: has_rush_vertical(state, world.player) and
|
||||
state.has_any([names.shadow_blade, names.gemini_laser], world.player))
|
||||
add_rule(world.get_entrance("To Doc Robot (Needle) - Air"),
|
||||
lambda state: has_rush_vertical(state, world.player))
|
||||
add_rule(world.get_entrance("To Doc Robot (Needle) - Crash"),
|
||||
lambda state: has_rush_jet(state, world.player))
|
||||
add_rule(world.get_entrance("To Doc Robot (Gemini) - Bubble"),
|
||||
lambda state: has_rush_vertical(state, world.player) and can_traverse_long_water(state, world.player))
|
||||
|
||||
for location in get_boss_locations("Wily Stage 1"):
|
||||
add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player))
|
||||
|
||||
for location in get_boss_locations("Wily Stage 2"):
|
||||
add_rule(world.get_location(location), lambda state: has_rush_jet(state, world.player))
|
||||
|
||||
# Wily 3 technically needs vertical
|
||||
# However, Wily 3 requires beating Wily 2, and Wily 2 explicitly needs Jet
|
||||
# So we can skip the additional rule on Wily 3
|
||||
|
||||
if world.options.consumables in (world.options.consumables.option_1up_etank,
|
||||
world.options.consumables.option_all):
|
||||
add_rule(world.get_location(names.needle_man_c2), lambda state: has_rush_jet(state, world.player))
|
||||
add_rule(world.get_location(names.gemini_man_c1), lambda state: has_rush_jet(state, world.player))
|
||||
add_rule(world.get_location(names.gemini_man_c3),
|
||||
lambda state: has_rush_vertical(state, world.player)
|
||||
or state.has_any([names.gemini_laser, names.shadow_blade], world.player))
|
||||
for location in (names.gemini_man_c6, names.gemini_man_c7, names.gemini_man_c10):
|
||||
add_rule(world.get_location(location), lambda state: has_any_rush(state, world.player))
|
||||
for location in get_oneup_locations("Hard Man Stage"):
|
||||
add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player))
|
||||
add_rule(world.get_location(names.top_man_c6), lambda state: has_rush_vertical(state, world.player))
|
||||
add_rule(world.get_location(names.doc_needle_c2), lambda state: has_rush_jet(state, world.player))
|
||||
add_rule(world.get_location(names.doc_needle_c3), lambda state: has_rush_jet(state, world.player))
|
||||
add_rule(world.get_location(names.doc_gemini_c1), lambda state: has_rush_vertical(state, world.player))
|
||||
add_rule(world.get_location(names.doc_gemini_c2), lambda state: has_rush_vertical(state, world.player))
|
||||
add_rule(world.get_location(names.wily_1_c8), lambda state: has_rush_vertical(state, world.player))
|
||||
for location in [names.wily_1_c4, names.wily_1_c8]:
|
||||
add_rule(world.get_location(location), lambda state: state.has(names.hard_knuckle, world.player))
|
||||
for location in get_oneup_locations("Wily Stage 2"):
|
||||
if location == names.wily_2_c3:
|
||||
continue
|
||||
add_rule(world.get_location(location), lambda state: has_rush_jet(state, world.player))
|
||||
if world.options.consumables in (world.options.consumables.option_weapon_health,
|
||||
world.options.consumables.option_all):
|
||||
add_rule(world.get_location(names.gemini_man_c2), lambda state: has_rush_vertical(state, world.player))
|
||||
add_rule(world.get_location(names.gemini_man_c4), lambda state: has_rush_vertical(state, world.player))
|
||||
add_rule(world.get_location(names.gemini_man_c5), lambda state: has_rush_vertical(state, world.player))
|
||||
for location in (names.gemini_man_c8, names.gemini_man_c9):
|
||||
add_rule(world.get_location(location), lambda state: has_any_rush(state, world.player))
|
||||
for location in get_energy_locations("Hard Man Stage"):
|
||||
if location == names.hard_man_c1:
|
||||
continue
|
||||
add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player))
|
||||
for location in (names.spark_man_c1, names.spark_man_c2):
|
||||
add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player))
|
||||
for location in [names.top_man_c2, names.top_man_c3, names.top_man_c4, names.top_man_c7]:
|
||||
add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player))
|
||||
for location in [names.wily_1_c5, names.wily_1_c6, names.wily_1_c7]:
|
||||
add_rule(world.get_location(location), lambda state: state.has(names.hard_knuckle, world.player))
|
||||
for location in [names.wily_1_c6, names.wily_1_c7, names.wily_1_c11, names.wily_1_c12]:
|
||||
add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player))
|
||||
for location in get_energy_locations("Wily Stage 2"):
|
||||
if location in (names.wily_2_c1, names.wily_2_c2, names.wily_2_c4):
|
||||
continue
|
||||
add_rule(world.get_location(location), lambda state: has_rush_jet(state, world.player))
|
||||
0
worlds/mm3/src/__init__.py
Normal file
0
worlds/mm3/src/__init__.py
Normal file
781
worlds/mm3/src/mm3_basepatch.asm
Normal file
781
worlds/mm3/src/mm3_basepatch.asm
Normal file
@@ -0,0 +1,781 @@
|
||||
norom
|
||||
!headersize = 16
|
||||
|
||||
!controller_flip = $14 ; only on first frame of input, used by crash man, etc
|
||||
!controller_mirror = $16
|
||||
!current_stage = $22
|
||||
!current_state = $60
|
||||
!completed_rbm_stages = $61
|
||||
!completed_doc_stages = $62
|
||||
!current_wily = $75
|
||||
!received_rbm_stages = $680
|
||||
!received_doc_stages = $681
|
||||
; !deathlink = $30, set to $0E
|
||||
!energylink_packet = $682
|
||||
!last_wily = $683
|
||||
!rbm_strobe = $684
|
||||
!sound_effect_strobe = $685
|
||||
!doc_robo_kills = $686
|
||||
!wily_stage_completion = $687
|
||||
;!received_items = $688
|
||||
!acquired_rush = $689
|
||||
|
||||
!current_weapon = $A0
|
||||
!current_health = $A2
|
||||
!received_weapons = $A3
|
||||
|
||||
'0' = $00
|
||||
'1' = $01
|
||||
'2' = $02
|
||||
'3' = $03
|
||||
'4' = $04
|
||||
'5' = $05
|
||||
'6' = $06
|
||||
'7' = $07
|
||||
'8' = $08
|
||||
'9' = $09
|
||||
'A' = $0A
|
||||
'B' = $0B
|
||||
'C' = $0C
|
||||
'D' = $0D
|
||||
'E' = $0E
|
||||
'F' = $0F
|
||||
'G' = $10
|
||||
'H' = $11
|
||||
'I' = $12
|
||||
'J' = $13
|
||||
'K' = $14
|
||||
'L' = $15
|
||||
'M' = $16
|
||||
'N' = $17
|
||||
'O' = $18
|
||||
'P' = $19
|
||||
'Q' = $1A
|
||||
'R' = $1B
|
||||
'S' = $1C
|
||||
'T' = $1D
|
||||
'U' = $1E
|
||||
'V' = $1F
|
||||
'W' = $20
|
||||
'X' = $21
|
||||
'Y' = $22
|
||||
'Z' = $23
|
||||
' ' = $25
|
||||
'.' = $26
|
||||
',' = $27
|
||||
'!' = $29
|
||||
'r' = $2A
|
||||
':' = $2B
|
||||
|
||||
; !consumable_checks = $0F80 ; have to find in-stage solutions for this, there's literally not enough ram
|
||||
|
||||
!CONTROLLER_SELECT = #$20
|
||||
!CONTROLLER_SELECT_START = #$30
|
||||
!CONTROLLER_ALL_BUTTON = #$F0
|
||||
|
||||
!PpuControl_2000 = $2000
|
||||
!PpuMask_2001 = $2001
|
||||
!PpuAddr_2006 = $2006
|
||||
!PpuData_2007 = $2007
|
||||
|
||||
;!LOAD_BANK = $C000
|
||||
|
||||
macro org(address,bank)
|
||||
if <bank> == $3E
|
||||
org <address>-$C000+($2000*<bank>)+!headersize ; org sets the position in the output file to write to (in norom, at least)
|
||||
base <address> ; base sets the position that all labels are relative to - this is necessary so labels will still start from $8000, instead of $0000 or somewhere
|
||||
else
|
||||
if <bank> == $3F
|
||||
org <address>-$E000+($2000*<bank>)+!headersize ; org sets the position in the output file to write to (in norom, at least)
|
||||
base <address> ; base sets the position that all labels are relative to - this is necessary so labels will still start from $8000, instead of $0000 or somewhere
|
||||
else
|
||||
if <address> >= $A000
|
||||
org <address>-$A000+($2000*<bank>)+!headersize
|
||||
base <address>
|
||||
else
|
||||
org <address>-$8000+($2000*<bank>)+!headersize
|
||||
base <address>
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endmacro
|
||||
|
||||
; capcom.....
|
||||
; i can't keep defending you like this
|
||||
|
||||
;P
|
||||
%org($BEBA, $13)
|
||||
RemoveP:
|
||||
db $25
|
||||
;A
|
||||
%org($BD7D, $13)
|
||||
RemoveA:
|
||||
db $25
|
||||
;S
|
||||
%org($BE7D, $13)
|
||||
RemoveS1:
|
||||
db $25
|
||||
;S
|
||||
%org($BDD5, $13)
|
||||
RemoveS2:
|
||||
db $25
|
||||
|
||||
;W
|
||||
%org($BDC7, $13)
|
||||
RemoveW:
|
||||
db $25
|
||||
;O
|
||||
%org($BEC7, $13)
|
||||
RemoveO:
|
||||
db $25
|
||||
;R
|
||||
%org($BDCF, $13)
|
||||
RemoveR:
|
||||
db $25
|
||||
;D
|
||||
%org($BECF, $13)
|
||||
RemoveD:
|
||||
db $25
|
||||
|
||||
%org($A17C, $02)
|
||||
AdjustWeaponRefill:
|
||||
; compare vs unreceived instead. Since the stage ends anyways, this just means you aren't granted the weapon if you don't have it already
|
||||
CMP #$1C
|
||||
BCS WeaponRefillJump
|
||||
|
||||
%org($A18B, $02)
|
||||
WeaponRefillJump:
|
||||
; just as a branch target
|
||||
|
||||
%org($A3BF, $02)
|
||||
FixPseudoSnake:
|
||||
JMP CheckFirstWep
|
||||
NOP
|
||||
|
||||
%org($A3CB, $02)
|
||||
FixPseudoRush:
|
||||
JMP CheckRushWeapon
|
||||
NOP
|
||||
|
||||
%org($BF80, $02)
|
||||
CheckRushWeapon:
|
||||
AND #$01
|
||||
BNE .Rush
|
||||
JMP $A3CF
|
||||
.Rush:
|
||||
LDA $A1
|
||||
CLC
|
||||
ADC $B4
|
||||
TAY
|
||||
LDA $00A2, Y
|
||||
BNE .Skip
|
||||
DEC $A1
|
||||
.Skip:
|
||||
JMP $A477
|
||||
|
||||
; don't even try to go past this point
|
||||
|
||||
%org($802F, $0B)
|
||||
HookBreakMan:
|
||||
JSR SetBreakMan
|
||||
NOP
|
||||
|
||||
%org($90BC, $18)
|
||||
BlockPassword:
|
||||
AND #$08 ; originally 0C, just block down inputs
|
||||
|
||||
%org($9258, $18)
|
||||
HookStageSelect:
|
||||
JSR ChangeStageMode
|
||||
NOP
|
||||
|
||||
%org($92F2, $18)
|
||||
AccessStageTarget:
|
||||
|
||||
%org($9316, $18)
|
||||
AccessStage:
|
||||
JSR RewireDocRobotAccess
|
||||
NOP #2
|
||||
BEQ AccessStageTarget
|
||||
|
||||
%org($9468, $18)
|
||||
HookWeaponGet:
|
||||
JSR WeaponReceived
|
||||
NOP #4
|
||||
|
||||
%org($9917, $18)
|
||||
GameOverStageSelect:
|
||||
; fix it returning to Wily 1
|
||||
CMP #$16
|
||||
|
||||
%org($9966, $18)
|
||||
SwapSelectTiles:
|
||||
; swaps when stage select face tiles should be shown
|
||||
JMP InvertSelectTiles
|
||||
NOP
|
||||
|
||||
%org($9A54, $18)
|
||||
SwapSelectSprites:
|
||||
JMP InvertSelectSprites
|
||||
NOP
|
||||
|
||||
%org($9AFF, $18)
|
||||
BreakManSelect:
|
||||
JSR ApplyLastWily
|
||||
NOP
|
||||
|
||||
%org($BE22, $1D)
|
||||
ConsumableHook:
|
||||
JMP CheckConsumable
|
||||
|
||||
%org($BE32, $1D)
|
||||
EnergyLinkHook:
|
||||
JSR EnergyLink
|
||||
|
||||
%org($A000, $1E)
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P"
|
||||
db $22, $45, $0C, "PLACEHOLDER 1"
|
||||
db $22, $65, $0C, "PLACEHOLDER 2"
|
||||
db $22, $85, $0C, "PLACEHOLDER 3"
|
||||
db $22, $A5, $0C, "PLACEHOLDER P", $FF
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P", $FF
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P", $FF
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P", $FF
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P", $FF
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P", $FF
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P", $FF
|
||||
db $21, $A5, $0C, "PLACEHOLDER 1"
|
||||
db $21, $C5, $0C, "PLACEHOLDER 2"
|
||||
db $21, $E5, $0C, "PLACEHOLDER 3"
|
||||
db $22, $05, $0C, "PLACEHOLDER P"
|
||||
db $22, $45, $0C, "PLACEHOLDER 1"
|
||||
db $22, $65, $0C, "PLACEHOLDER 2"
|
||||
db $22, $85, $0C, "PLACEHOLDER 3"
|
||||
db $22, $A5, $0C, "PLACEHOLDER P", $FF
|
||||
|
||||
ShowItemString:
|
||||
STY $04
|
||||
LDA ItemLower,X
|
||||
STA $02
|
||||
LDA ItemUpper,X
|
||||
STA $03
|
||||
LDY #$00
|
||||
.LoadString:
|
||||
LDA ($02),Y
|
||||
ORA $10
|
||||
STA $0780,Y
|
||||
BMI .Return
|
||||
INY
|
||||
LDA ($02),Y
|
||||
STA $0780,Y
|
||||
INY
|
||||
LDA ($02),Y
|
||||
STA $0780,Y
|
||||
STA $00
|
||||
INY
|
||||
.LoadCharacters:
|
||||
LDA ($02),Y
|
||||
STA $0780,Y
|
||||
INY
|
||||
DEC $00
|
||||
BPL .LoadCharacters
|
||||
BMI .LoadString
|
||||
.Return:
|
||||
STA $19
|
||||
LDY $04
|
||||
RTS
|
||||
|
||||
ItemUpper:
|
||||
db $A0, $A0, $A0, $A1, $A1, $A1, $A1, $A2, $A2
|
||||
|
||||
ItemLower:
|
||||
db $00, $81, $C2, $03, $44, $85, $C6, $07, $47
|
||||
|
||||
%org($C8F7, $3E)
|
||||
RemoveRushCoil:
|
||||
NOP #4
|
||||
|
||||
%org($CA73, $3E)
|
||||
HookController:
|
||||
JMP ControllerHook
|
||||
NOP
|
||||
|
||||
%org($DA18, $3E)
|
||||
NullWeaponGet:
|
||||
NOP #5 ; TODO: see if I can reroute this write instead for nicer timings
|
||||
|
||||
%org($DB99, $3E)
|
||||
HookMidDoc:
|
||||
JSR SetMidDoc
|
||||
NOP
|
||||
|
||||
%org($DBB0, $3E)
|
||||
HoodEndDoc:
|
||||
JSR SetEndDoc
|
||||
NOP
|
||||
|
||||
%org($DC57, $3E)
|
||||
RerouteStageComplete:
|
||||
LDA $60
|
||||
JSR SetStageComplete
|
||||
NOP #2
|
||||
|
||||
%org($DC6F, $3E)
|
||||
RerouteRushMarine:
|
||||
JMP SetRushMarine
|
||||
NOP
|
||||
|
||||
%org($DC6A, $3E)
|
||||
RerouteRushJet:
|
||||
JMP SetRushJet
|
||||
NOP
|
||||
|
||||
%org($DC78, $3E)
|
||||
RerouteWilyComplete:
|
||||
JMP SetEndWily
|
||||
NOP
|
||||
EndWilyReturn:
|
||||
|
||||
%org($DF81, $3E)
|
||||
NullBreak:
|
||||
NOP #5 ; nop break man giving every weapon
|
||||
|
||||
%org($E15F, $3F)
|
||||
Wily4:
|
||||
JMP Wily4Comparison
|
||||
NOP
|
||||
|
||||
|
||||
%org($F340, $3F)
|
||||
RewireDocRobotAccess:
|
||||
LDA !current_state
|
||||
BNE .DocRobo
|
||||
LDA !received_rbm_stages
|
||||
SEC
|
||||
BCS .Return
|
||||
.DocRobo:
|
||||
LDA !received_doc_stages
|
||||
.Return:
|
||||
AND $9DED,Y
|
||||
RTS
|
||||
|
||||
ChangeStageMode:
|
||||
; also handles hot reload of stage select
|
||||
; kinda broken, sprites don't disappear and palettes go wonky with Break Man access
|
||||
; but like, it functions!
|
||||
LDA !sound_effect_strobe
|
||||
BEQ .Continue
|
||||
JSR $F89A
|
||||
LDA #$00
|
||||
STA !sound_effect_strobe
|
||||
.Continue:
|
||||
LDA $14
|
||||
AND #$20
|
||||
BEQ .Next
|
||||
LDA !current_state
|
||||
BNE .Set
|
||||
LDA !completed_doc_stages
|
||||
CMP #$C5
|
||||
BEQ .BreakMan
|
||||
LDA #$09
|
||||
SEC
|
||||
BCS .Set
|
||||
.EarlyReturn:
|
||||
LDA $14
|
||||
AND #$90
|
||||
RTS
|
||||
.BreakMan:
|
||||
LDA #$12
|
||||
.Set:
|
||||
EOR !current_state
|
||||
STA !current_state
|
||||
LDA #$01
|
||||
STA !rbm_strobe
|
||||
.Next:
|
||||
LDA !rbm_strobe
|
||||
BEQ .EarlyReturn
|
||||
LDA #$00
|
||||
STA !rbm_strobe
|
||||
; Clear the sprite buffer
|
||||
LDX #$98
|
||||
.Loop:
|
||||
LDA #$00
|
||||
STA $01FF, X
|
||||
DEX
|
||||
STA $01FF, X
|
||||
DEX
|
||||
STA $01FF, X
|
||||
DEX
|
||||
LDA #$F8
|
||||
STA $01FF, X
|
||||
DEX
|
||||
CPX #$00
|
||||
BNE .Loop
|
||||
; Break Man Sprites
|
||||
LDX #$24
|
||||
.Loop2:
|
||||
LDA #$00
|
||||
STA $02DB, X
|
||||
DEX
|
||||
STA $02DB, X
|
||||
DEX
|
||||
STA $02DB, X
|
||||
DEX
|
||||
LDA #$F8
|
||||
STA $02DB, X
|
||||
DEX
|
||||
CPX #$00
|
||||
BNE .Loop2
|
||||
; Swap out the tilemap and write sprites
|
||||
LDY #$10
|
||||
LDA $11
|
||||
BMI .B1
|
||||
LDA $FD
|
||||
EOR #$01
|
||||
ASL A
|
||||
ASL A
|
||||
STA $10
|
||||
LDA #$01
|
||||
JSR $E8B4
|
||||
LDA #$00
|
||||
STA $70
|
||||
STA $EE
|
||||
.B3:
|
||||
LDA $10
|
||||
PHA
|
||||
JSR $EF8C
|
||||
PLA
|
||||
STA $10
|
||||
JSR $FF21
|
||||
LDA $70
|
||||
BNE .B3
|
||||
JSR $995C
|
||||
LDX #$03
|
||||
JSR $939E
|
||||
JSR $FF21
|
||||
LDX #$04
|
||||
JSR $939E
|
||||
LDA $FD
|
||||
EOR #$01
|
||||
STA $FD
|
||||
LDY #$00
|
||||
LDA #$7E
|
||||
STA $E9
|
||||
JSR $FF3C
|
||||
.B1:
|
||||
LDX #$00
|
||||
; palettes
|
||||
.B2:
|
||||
LDA $9C33,Y
|
||||
STA $0600,X
|
||||
LDA $9C23,Y
|
||||
STA $0610,X
|
||||
INY
|
||||
INX
|
||||
CPX #$10
|
||||
BNE .B2
|
||||
LDA #$FF
|
||||
STA $18
|
||||
LDA #$01
|
||||
STA $12
|
||||
LDA #$03
|
||||
STA $13
|
||||
LDA $11
|
||||
JSR $99FA
|
||||
LDA $14
|
||||
AND #$90
|
||||
RTS
|
||||
|
||||
InvertSelectTiles:
|
||||
LDY !current_state
|
||||
BNE .DocRobo
|
||||
AND !received_rbm_stages
|
||||
SEC
|
||||
BCS .Compare
|
||||
.DocRobo:
|
||||
AND !received_doc_stages
|
||||
.Compare:
|
||||
BNE .False
|
||||
JMP $996A
|
||||
.False:
|
||||
JMP $99BA
|
||||
|
||||
InvertSelectSprites:
|
||||
LDY !current_state
|
||||
BNE .DocRobo
|
||||
AND !received_rbm_stages
|
||||
SEC
|
||||
BCS .Compare
|
||||
.DocRobo:
|
||||
AND !received_doc_stages
|
||||
.Compare:
|
||||
BNE .False
|
||||
JMP $9A58
|
||||
.False:
|
||||
JMP $9A6D
|
||||
|
||||
SetStageComplete:
|
||||
CMP #$00
|
||||
BNE .DocRobo
|
||||
LDA !completed_rbm_stages
|
||||
ORA $DEC2, Y
|
||||
STA !completed_rbm_stages
|
||||
SEC
|
||||
BCS .Return
|
||||
.DocRobo:
|
||||
LDA !completed_doc_stages
|
||||
ORA $DEC2, Y
|
||||
STA !completed_doc_stages
|
||||
.Return:
|
||||
RTS
|
||||
|
||||
ControllerHook:
|
||||
; Jump in here too for sfx
|
||||
LDA !sound_effect_strobe
|
||||
BEQ .Next
|
||||
JSR $F89A
|
||||
LDA #$00
|
||||
STA !sound_effect_strobe
|
||||
.Next:
|
||||
LDA !controller_mirror
|
||||
CMP !CONTROLLER_ALL_BUTTON
|
||||
BNE .Continue
|
||||
JMP $CBB1
|
||||
.Continue:
|
||||
LDA !controller_flip
|
||||
AND #$10 ; start
|
||||
JMP $CA77
|
||||
|
||||
SetRushMarine:
|
||||
LDA #$01
|
||||
SEC
|
||||
BCS SetRushAcquire
|
||||
|
||||
SetRushJet:
|
||||
LDA #$02
|
||||
SEC
|
||||
BCS SetRushAcquire
|
||||
|
||||
SetRushAcquire:
|
||||
ORA !acquired_rush
|
||||
STA !acquired_rush
|
||||
RTS
|
||||
|
||||
ApplyLastWily:
|
||||
LDA !controller_mirror
|
||||
AND !CONTROLLER_SELECT
|
||||
BEQ .LastWily
|
||||
.Default:
|
||||
LDA #$00
|
||||
SEC
|
||||
BCS .Set
|
||||
.LastWily:
|
||||
LDA !last_wily
|
||||
BEQ .Default
|
||||
SEC
|
||||
SBC #$0C
|
||||
.Set:
|
||||
STA $75 ; wily index
|
||||
LDA #$03
|
||||
STA !current_stage
|
||||
RTS
|
||||
|
||||
SetMidDoc:
|
||||
LDA !current_stage
|
||||
SEC
|
||||
SBC #$08
|
||||
ASL
|
||||
TAY
|
||||
LDA #$01
|
||||
.Loop:
|
||||
CPY #$00
|
||||
BEQ .Return
|
||||
DEY
|
||||
ASL
|
||||
SEC
|
||||
BCS .Loop
|
||||
.Return:
|
||||
ORA !doc_robo_kills
|
||||
STA !doc_robo_kills
|
||||
LDA #$00
|
||||
STA $30
|
||||
RTS
|
||||
|
||||
SetEndDoc:
|
||||
LDA !current_stage
|
||||
SEC
|
||||
SBC #$08
|
||||
ASL
|
||||
TAY
|
||||
INY
|
||||
LDA #$01
|
||||
.Loop:
|
||||
CPY #$00
|
||||
BEQ .Set
|
||||
DEY
|
||||
ASL
|
||||
SEC
|
||||
BCS .Loop
|
||||
.Set:
|
||||
ORA !doc_robo_kills
|
||||
STA !doc_robo_kills
|
||||
.Return:
|
||||
LDA #$0D
|
||||
STA $30
|
||||
RTS
|
||||
|
||||
SetEndWily:
|
||||
LDA !current_wily
|
||||
PHA
|
||||
CLC
|
||||
ADC #$0C
|
||||
STA !last_wily
|
||||
PLA
|
||||
TAX
|
||||
LDA #$01
|
||||
.WLoop:
|
||||
CPX #$00
|
||||
BEQ .WContinue
|
||||
DEX
|
||||
ASL A
|
||||
SEC
|
||||
BCS .WLoop
|
||||
.WContinue:
|
||||
ORA !wily_stage_completion
|
||||
STA !wily_stage_completion
|
||||
INC !current_wily
|
||||
LDA #$9C
|
||||
JMP EndWilyReturn
|
||||
|
||||
|
||||
SetBreakMan:
|
||||
LDA #$80
|
||||
ORA !wily_stage_completion
|
||||
STA !wily_stage_completion
|
||||
LDA #$16
|
||||
STA $22
|
||||
RTS
|
||||
|
||||
CheckFirstWep:
|
||||
LDA $B4
|
||||
BEQ .SetNone
|
||||
TAY
|
||||
.Loop:
|
||||
LDA $00A2,Y
|
||||
BMI .SetNew
|
||||
INY
|
||||
CPY #$0C
|
||||
BEQ .SetSame
|
||||
BCC .Loop
|
||||
.SetSame:
|
||||
LDA #$80
|
||||
STA $A1
|
||||
JMP $A3A1
|
||||
.SetNew:
|
||||
TYA
|
||||
SEC
|
||||
SBC $B4
|
||||
BCS .Set
|
||||
.SetNone:
|
||||
LDA #$00
|
||||
.Set:
|
||||
STA $A1
|
||||
JMP $A3DE
|
||||
|
||||
Wily4Comparison:
|
||||
TYA
|
||||
PHA
|
||||
TXA
|
||||
PHA
|
||||
LDY #$00
|
||||
LDX #$08
|
||||
LDA #$01
|
||||
.Loop:
|
||||
PHA
|
||||
AND $6E
|
||||
BEQ .Skip
|
||||
INY
|
||||
.Skip:
|
||||
PLA
|
||||
ASL
|
||||
DEX
|
||||
BNE .Loop
|
||||
print "Wily 4 Requirement:", hex(realbase())
|
||||
CPY #$08
|
||||
BCC .Return
|
||||
LDA #$FF
|
||||
STA $6E
|
||||
.Return:
|
||||
PLA
|
||||
TAX
|
||||
PLA
|
||||
TAY
|
||||
LDA #$0C
|
||||
STA $EC
|
||||
RTS
|
||||
|
||||
; out of space here :(
|
||||
|
||||
%org($FDBA, $3F)
|
||||
WeaponReceived:
|
||||
TAX
|
||||
LDA $F5
|
||||
PHA
|
||||
LDA #$1E
|
||||
STA $F5
|
||||
JSR $FF6B
|
||||
TXA
|
||||
JSR ShowItemString
|
||||
PLA
|
||||
STA $F5
|
||||
JSR $FF6B
|
||||
RTS
|
||||
|
||||
CheckConsumable:
|
||||
STA $0150, Y
|
||||
LDA $0320, X
|
||||
CMP #$64
|
||||
BMI .Return
|
||||
print "Consumables (replace 67): ", hex(realbase())
|
||||
CMP #$6A
|
||||
BPL .Return
|
||||
LDA #$00
|
||||
STA $0300, X
|
||||
JMP $BE49
|
||||
.Return:
|
||||
JMP $BE25
|
||||
|
||||
EnergyLink:
|
||||
print "Energylink: ", hex(realbase())
|
||||
LDA #$01
|
||||
BEQ .Return
|
||||
TYA
|
||||
STA !energylink_packet
|
||||
LDA #$49
|
||||
STA $00
|
||||
.Return:
|
||||
LDA $BDEC, Y
|
||||
RTS
|
||||
|
||||
; out of room here :(
|
||||
8
worlds/mm3/src/patch_mm3base.py
Normal file
8
worlds/mm3/src/patch_mm3base.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
os.chdir(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
mm3 = bytearray(open("Mega Man 3 (USA).nes", 'rb').read())
|
||||
mm3[0x3C010:0x3C010] = [0] * 0x40000
|
||||
mm3[0x4] = 0x20 # have to do it here, because we don't this in the basepatch itself
|
||||
open("mm3_basepatch.nes", 'wb').write(mm3)
|
||||
0
worlds/mm3/test/__init__.py
Normal file
0
worlds/mm3/test/__init__.py
Normal file
5
worlds/mm3/test/bases.py
Normal file
5
worlds/mm3/test/bases.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from test.bases import WorldTestBase
|
||||
|
||||
|
||||
class MM3TestBase(WorldTestBase):
|
||||
game = "Mega Man 3"
|
||||
105
worlds/mm3/test/test_weakness.py
Normal file
105
worlds/mm3/test/test_weakness.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from math import ceil
|
||||
|
||||
from .bases import MM3TestBase
|
||||
from ..rules import minimum_weakness_requirement, bosses
|
||||
|
||||
|
||||
# Need to figure out how this test should work
|
||||
def validate_wily_4(base: MM3TestBase) -> None:
|
||||
world = base.multiworld.worlds[base.player]
|
||||
weapon_damage = world.weapon_damage
|
||||
weapon_costs = {
|
||||
0: 0,
|
||||
1: 0.25,
|
||||
2: 2,
|
||||
3: 1,
|
||||
4: 2,
|
||||
5: 7, # Not really, but we can really only rely on Top for one RBM
|
||||
6: 0.5,
|
||||
7: 2,
|
||||
8: 0.5,
|
||||
}
|
||||
boss_health = {boss: 0x1C for boss in range(8)}
|
||||
weapon_energy = {key: float(0x1C) for key in weapon_costs}
|
||||
weapon_boss = {boss: {weapon: world.weapon_damage[weapon][boss] for weapon in world.weapon_damage}
|
||||
for boss in range(8)}
|
||||
flexibility = {
|
||||
boss: (
|
||||
sum(damage_value > 0 for damage_value in
|
||||
weapon_damages.values()) # Amount of weapons that hit this boss
|
||||
* sum(weapon_damages.values()) # Overall damage that those weapons do
|
||||
)
|
||||
for boss, weapon_damages in weapon_boss.items()
|
||||
}
|
||||
boss_flexibility = sorted(flexibility, key=flexibility.get) # Fast way to sort dict by value
|
||||
used_weapons: dict[int, set[int]] = {i: set() for i in range(8)}
|
||||
for boss in boss_flexibility:
|
||||
boss_damage = weapon_boss[boss]
|
||||
weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in
|
||||
boss_damage.items() if weapon_energy[weapon] > 0}
|
||||
while boss_health[boss] > 0:
|
||||
if boss_damage[0] > 0:
|
||||
boss_health[boss] = 0 # if we can buster, we should buster
|
||||
continue
|
||||
highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys()))
|
||||
uses = weapon_energy[wp] // weapon_costs[wp]
|
||||
used_weapons[boss].add(wp)
|
||||
if int(uses * boss_damage[wp]) > boss_health[boss]:
|
||||
used = ceil(boss_health[boss] / boss_damage[wp])
|
||||
weapon_energy[wp] -= weapon_costs[wp] * used
|
||||
boss_health[boss] = 0
|
||||
elif highest <= 0:
|
||||
# we are out of weapons that can actually damage the boss
|
||||
base.fail(f"Ran out of weapon energy to damage "
|
||||
f"{next(name for name in bosses if bosses[name] == boss)}\n"
|
||||
f"Seed: {base.multiworld.seed}\n"
|
||||
f"Damage Table: {weapon_damage}")
|
||||
else:
|
||||
# drain the weapon and continue
|
||||
boss_health[boss] -= int(uses * boss_damage[wp])
|
||||
weapon_energy[wp] -= weapon_costs[wp] * uses
|
||||
weapon_weight.pop(wp)
|
||||
|
||||
|
||||
class WeaknessTests(MM3TestBase):
|
||||
def test_that_every_boss_has_a_weakness(self) -> None:
|
||||
world = self.multiworld.worlds[self.player]
|
||||
weapon_damage = world.weapon_damage
|
||||
for boss in range(22):
|
||||
if not any(weapon_damage[weapon][boss] >= minimum_weakness_requirement[weapon] for weapon in range(9)):
|
||||
self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}")
|
||||
|
||||
def test_wily_4(self) -> None:
|
||||
validate_wily_4(self)
|
||||
|
||||
|
||||
class StrictWeaknessTests(WeaknessTests):
|
||||
options = {
|
||||
"strict_weakness": True,
|
||||
}
|
||||
|
||||
|
||||
class RandomWeaknessTests(WeaknessTests):
|
||||
options = {
|
||||
"random_weakness": "randomized"
|
||||
}
|
||||
|
||||
|
||||
class ShuffledWeaknessTests(WeaknessTests):
|
||||
options = {
|
||||
"random_weakness": "shuffled"
|
||||
}
|
||||
|
||||
|
||||
class RandomStrictWeaknessTests(WeaknessTests):
|
||||
options = {
|
||||
"strict_weakness": True,
|
||||
"random_weakness": "randomized",
|
||||
}
|
||||
|
||||
|
||||
class ShuffledStrictWeaknessTests(WeaknessTests):
|
||||
options = {
|
||||
"strict_weakness": True,
|
||||
"random_weakness": "shuffled"
|
||||
}
|
||||
63
worlds/mm3/text.py
Normal file
63
worlds/mm3/text.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from collections import defaultdict
|
||||
from typing import DefaultDict
|
||||
|
||||
MM3_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda: 0x25, {
|
||||
'0': 0x00,
|
||||
'1': 0x01,
|
||||
'2': 0x02,
|
||||
'3': 0x03,
|
||||
'4': 0x04,
|
||||
'5': 0x05,
|
||||
'6': 0x06,
|
||||
'7': 0x07,
|
||||
'8': 0x08,
|
||||
'9': 0x09,
|
||||
'A': 0x0A,
|
||||
'B': 0x0B,
|
||||
'C': 0x0C,
|
||||
'D': 0x0D,
|
||||
'E': 0x0E,
|
||||
'F': 0x0F,
|
||||
'G': 0x10,
|
||||
'H': 0x11,
|
||||
'I': 0x12,
|
||||
'J': 0x13,
|
||||
'K': 0x14,
|
||||
'L': 0x15,
|
||||
'M': 0x16,
|
||||
'N': 0x17,
|
||||
'O': 0x18,
|
||||
'P': 0x19,
|
||||
'Q': 0x1A,
|
||||
'R': 0x1B,
|
||||
'S': 0x1C,
|
||||
'T': 0x1D,
|
||||
'U': 0x1E,
|
||||
'V': 0x1F,
|
||||
'W': 0x20,
|
||||
'X': 0x21,
|
||||
'Y': 0x22,
|
||||
'Z': 0x23,
|
||||
' ': 0x25,
|
||||
'.': 0x26,
|
||||
',': 0x27,
|
||||
'\'': 0x28,
|
||||
'!': 0x29,
|
||||
':': 0x2B
|
||||
})
|
||||
|
||||
|
||||
class MM3TextEntry:
|
||||
def __init__(self, text: str = "", y_coords: int = 0xA5, row: int = 0x21):
|
||||
self.target_area: int = row # don't change
|
||||
self.coords: int = y_coords # 0xYX, Y can only be increments of 0x20
|
||||
self.text: str = text
|
||||
|
||||
def resolve(self) -> bytes:
|
||||
data = bytearray()
|
||||
data.append(self.target_area)
|
||||
data.append(self.coords)
|
||||
data.append(12)
|
||||
data.extend([MM3_WEAPON_ENCODING[x] for x in self.text.upper()])
|
||||
data.extend([0x25] * (13 - len(self.text)))
|
||||
return bytes(data)
|
||||
@@ -28,6 +28,7 @@ class MuseDashCollections:
|
||||
"Miku in Museland", # Paid DLC not included in Muse Plus
|
||||
"Rin Len's Mirrorland", # Paid DLC not included in Muse Plus
|
||||
"MSR Anthology_Vol.02", # Goes away January 26, 2026.
|
||||
"MD-level Tactical Training Blu-ray", # Goes away December 27, 2025.
|
||||
]
|
||||
|
||||
REMOVED_SONGS = [
|
||||
@@ -38,6 +39,7 @@ class MuseDashCollections:
|
||||
"Tsukuyomi Ni Naru Replaced",
|
||||
"Heart Message feat. Aoi Tokimori Secret",
|
||||
"Meow Rock feat. Chun Ge, Yuan Shen",
|
||||
"Stra Stella Secret",
|
||||
]
|
||||
|
||||
song_items = SONG_DATA
|
||||
|
||||
@@ -625,7 +625,7 @@ SONG_DATA: Dict[str, SongData] = {
|
||||
"Synthesis.": SongData(2900749, "83-1", "Cosmic Radio 2024", True, 6, 8, 10),
|
||||
"COSMiC FANFARE!!!!": SongData(2900750, "83-2", "Cosmic Radio 2024", False, 7, 9, 11),
|
||||
"Sharp Bubbles": SongData(2900751, "83-3", "Cosmic Radio 2024", True, 7, 9, 11),
|
||||
"Replay": SongData(2900752, "83-4", "Cosmic Radio 2024", True, 5, 7, 9),
|
||||
"Replay": SongData(2900752, "83-4", "Cosmic Radio 2024", False, 5, 7, 9),
|
||||
"Cosmic Dusty Girl": SongData(2900753, "83-5", "Cosmic Radio 2024", True, 5, 7, 9),
|
||||
"Meow Rock feat. Chun Ge, Yuan Shen": SongData(2900754, "84-0", "Muse Dash・Legend", True, None, None, None),
|
||||
"Even if you make an old radio song with AI": SongData(2900755, "84-1", "Muse Dash・Legend", False, 3, 6, 8),
|
||||
@@ -677,4 +677,30 @@ SONG_DATA: Dict[str, SongData] = {
|
||||
"City Lights": SongData(2900801, "90-3", "MEDIUM5 Echoes", True, 4, 6, 9),
|
||||
"Polaris Wandering Night": SongData(2900802, "90-4", "MEDIUM5 Echoes", True, 5, 8, 10),
|
||||
"Chasing the Moonlight": SongData(2900803, "90-5", "MEDIUM5 Echoes", True, 4, 6, 8),
|
||||
"WILDCARD": SongData(2900804, "91-0", "48 Hours After Discharge", True, 3, 6, 9),
|
||||
"It was all just a dream!": SongData(2900805, "91-1", "48 Hours After Discharge", True, 5, 7, 9),
|
||||
"Science": SongData(2900806, "91-2", "48 Hours After Discharge", False, 4, 7, 9),
|
||||
"Hit Maker": SongData(2900807, "91-3", "48 Hours After Discharge", False, 4, 6, 9),
|
||||
"THX 4 playing": SongData(2900808, "91-4", "48 Hours After Discharge", True, 3, 5, 8),
|
||||
"Theory of Existence": SongData(2900809, "91-5", "48 Hours After Discharge", True, 4, 6, 9),
|
||||
"Kirakira Noel Story!!": SongData(2900810, "43-68", "MD Plus Project", False, 6, 8, 10),
|
||||
"Fantasista LAST END": SongData(2900811, "92-0", "HARDCORE MOTTO TANO*C", True, 7, 9, 11),
|
||||
"Colorful Universe": SongData(2900812, "92-1", "HARDCORE MOTTO TANO*C", True, 3, 6, 9),
|
||||
"Future Flux": SongData(2900813, "92-2", "HARDCORE MOTTO TANO*C", True, 5, 8, 10),
|
||||
"SOMEONE STOP ME!!!": SongData(2900814, "92-3", "HARDCORE MOTTO TANO*C", True, 6, 8, 10),
|
||||
"Azathoth": SongData(2900815, "92-4", "HARDCORE MOTTO TANO*C", True, 6, 8, 10),
|
||||
"Change the Game feat. Iori Matsunaga": SongData(2900816, "92-5", "HARDCORE MOTTO TANO*C", False, 6, 8, 10),
|
||||
"Stra Stella Secret": SongData(2900817, "0-59", "Default Music", False, 6, 8, 10),
|
||||
"Stra Stella": SongData(2900818, "0-60", "Default Music", False, 1, 4, None),
|
||||
"Ultra-Digital Super Detox": SongData(2900819, "43-69", "MD Plus Project", False, 3, 6, 9),
|
||||
"Otsukimi Koete Otsukiai": SongData(2900820, "43-70", "MD Plus Project", True, 6, 8, 10),
|
||||
"Obenkyou Time": SongData(2900821, "43-71", "MD Plus Project", False, 6, 8, 11),
|
||||
"Retry Now": SongData(2900822, "43-72", "MD Plus Project", False, 3, 6, 9),
|
||||
"Master Bancho's Sushi Class ": SongData(2900823, "93-0", "Welcome to the Blue Hole!", False, None, None, None),
|
||||
"CHAOTiC BATTLE": SongData(2900824, "94-0", "Cosmic Radio 2025", False, 7, 9, 11),
|
||||
"FATAL GAME": SongData(2900825, "94-1", "Cosmic Radio 2025", False, 3, 6, 9),
|
||||
"Aria": SongData(2900826, "94-2", "Cosmic Radio 2025", False, 4, 6, 9),
|
||||
"+1 UNKNOWN -NUMBER": SongData(2900827, "94-3", "Cosmic Radio 2025", True, 4, 7, 10),
|
||||
"To the Beyond, from the Nameless Seaside": SongData(2900828, "94-4", "Cosmic Radio 2025", False, 5, 8, 10),
|
||||
"REK421": SongData(2900829, "94-5", "Cosmic Radio 2025", True, 7, 9, 11),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"game": "Muse Dash",
|
||||
"authors": ["DeamonHunter"],
|
||||
"world_version": "1.5.26",
|
||||
"world_version": "1.5.29",
|
||||
"minimum_ap_version": "0.6.3"
|
||||
}
|
||||
@@ -272,7 +272,7 @@ def patch_rom(world, rom):
|
||||
world_str = ""
|
||||
rom.write_bytes(rom.sym('WORLD_STRING_TXT'), makebytes(world_str, 12))
|
||||
|
||||
time_str = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M") + " UTC"
|
||||
time_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M") + " UTC"
|
||||
rom.write_bytes(rom.sym('TIME_STRING_TXT'), makebytes(time_str, 25))
|
||||
|
||||
rom.write_byte(rom.sym('CFG_SHOW_SETTING_INFO'), 0x01)
|
||||
|
||||
@@ -7,7 +7,7 @@ Da wir BizHawk benutzen, gilt diese Anleitung nur für Windows und Linux.
|
||||
## Benötigte Software
|
||||
|
||||
- BizHawk: [BizHawk Veröffentlichungen von TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Version 2.3.1 und später werden unterstützt. Version 2.10 ist empfohlen.
|
||||
- Version 2.10 und neuer werden unterstützt. Version 2.10 ist empfohlen.
|
||||
- Detailierte Installtionsanweisungen für BizHawk können über den obrigen Link gefunden werden.
|
||||
- Windows-Benutzer müssen die Prerequisiten installiert haben. Diese können ebenfalls über
|
||||
den obrigen Link gefunden werden.
|
||||
@@ -19,11 +19,6 @@ Da wir BizHawk benutzen, gilt diese Anleitung nur für Windows und Linux.
|
||||
|
||||
Sobald Bizhawk einmal installiert wurde, öffne **EmuHawk** und ändere die folgenen Einsteluungen:
|
||||
|
||||
- (≤ 2.8) Gehe zu `Config > Customize`. Wechlse zu dem `Advanced`-Reiter, wechsle dann den `Lua Core` von "NLua+KopiLua" zu
|
||||
`"Lua+LuaInterface"`. Starte danach EmuHawk neu. Dies ist zwingend notwendig, damit die Lua-Scripts, mit denen man sich mit dem Client verbindet, ordnungsgemäß funktionieren.
|
||||
**ANMERKUNG: Selbst wenn "Lua+LuaInterface" bereits ausgewählt ist, wechsle zwischen den beiden Optionen umher und**
|
||||
**wähle es erneut aus. Neue Installationen oder Versionen von EmuHawk neigen dazu "Lua+LuaInterface" als die**
|
||||
**Standard-Option anzuzeigen, aber laden dennoch "NLua+KopiLua", bis dieser Schritt getan ist.**
|
||||
- Unter `Config > Customize > Advanced`, gehe sicher dass der Haken bei `AutoSaveRAM` ausgeählt ist, und klicke dann
|
||||
den 5s-Knopf. Dies verringert die Wahrscheinlichkeit den Speicherfrotschritt zu verlieren, sollte der Emulator mal
|
||||
abstürzen.
|
||||
|
||||
@@ -7,7 +7,7 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst
|
||||
## Required Software
|
||||
|
||||
- BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Version 2.3.1 and later are supported. Version 2.10 is recommended for stability.
|
||||
- Version 2.10 and later are supported. Version 2.10 is recommended for stability.
|
||||
- Detailed installation instructions for BizHawk can be found at the above link.
|
||||
- Windows users must run the prereq installer first, which can also be found at the above link.
|
||||
- The built-in Archipelago client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
@@ -17,11 +17,6 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst
|
||||
|
||||
Once BizHawk has been installed, open EmuHawk and change the following settings:
|
||||
|
||||
- (≤ 2.8) Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to
|
||||
"Lua+LuaInterface". Then restart EmuHawk. This is required for the Lua script to function correctly.
|
||||
**NOTE: Even if "Lua+LuaInterface" is already selected, toggle between the two options and reselect it. Fresh installs**
|
||||
**of newer versions of EmuHawk have a tendency to show "Lua+LuaInterface" as the default selected option but still load**
|
||||
**"NLua+KopiLua" until this step is done.**
|
||||
- Under Config > Customize > Advanced, make sure the box for AutoSaveRAM is checked, and click the 5s button.
|
||||
This reduces the possibility of losing save data in emulator crashes.
|
||||
- Under Config > Customize, check the "Run in background" and "Accept background input" boxes. This will allow you to
|
||||
|
||||
@@ -7,7 +7,7 @@ Comme nous utilisons BizHawk, ce guide s'applique uniquement aux systèmes Windo
|
||||
## Logiciel requis
|
||||
|
||||
- BizHawk : [Sorties BizHawk de TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Les versions 2.3.1 et ultérieures sont prises en charge. La version 2.10 est recommandée pour des raisons de stabilité.
|
||||
- Les versions 2.10 et ultérieures sont prises en charge. La version 2.10 est recommandée pour des raisons de stabilité.
|
||||
- Des instructions d'installation détaillées pour BizHawk peuvent être trouvées sur le lien ci-dessus.
|
||||
- Les utilisateurs Windows doivent d'abord exécuter le programme d'installation des prérequis, qui peut également être trouvé sur le lien ci-dessus.
|
||||
- Le client Archipelago intégré, qui peut être installé [ici](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
@@ -18,10 +18,6 @@ Comme nous utilisons BizHawk, ce guide s'applique uniquement aux systèmes Windo
|
||||
|
||||
Une fois BizHawk installé, ouvrez EmuHawk et modifiez les paramètres suivants :
|
||||
|
||||
- (≤ 2,8) Allez dans Config > Personnaliser. Passez à l'onglet Avancé, puis faites passer le Lua Core de "NLua+KopiLua" à
|
||||
"Lua+LuaInterface". Puis redémarrez EmuHawk. Ceci est nécessaire pour que le script Lua fonctionne correctement.
|
||||
**REMARQUE : Même si « Lua+LuaInterface » est déjà sélectionné, basculez entre les deux options et resélectionnez-la. Nouvelles installations**
|
||||
**des versions plus récentes d'EmuHawk ont tendance à afficher "Lua+LuaInterface" comme option sélectionnée par défaut mais ce pendant refait l'épate juste au dessus par précautions**
|
||||
- Sous Config > Personnaliser > Avancé, assurez-vous que la case AutoSaveRAM est cochée et cliquez sur le bouton 5s.
|
||||
Cela réduit la possibilité de perdre des données de sauvegarde en cas de crash de l'émulateur.
|
||||
- Sous Config > Personnaliser, cochez les cases « Exécuter en arrière-plan » et « Accepter la saisie en arrière-plan ». Cela vous permettra continuez à jouer en arrière-plan, même si une autre fenêtre est sélectionnée.
|
||||
|
||||
@@ -123,6 +123,7 @@ class PokemonEmeraldWorld(World):
|
||||
blacklisted_wilds: Set[int]
|
||||
blacklisted_starters: Set[int]
|
||||
blacklisted_opponent_pokemon: Set[int]
|
||||
allowed_dexsanity_species: set[int]
|
||||
hm_requirements: Dict[str, Union[int, List[str]]]
|
||||
auth: bytes
|
||||
|
||||
@@ -142,6 +143,7 @@ class PokemonEmeraldWorld(World):
|
||||
self.blacklisted_wilds = set()
|
||||
self.blacklisted_starters = set()
|
||||
self.blacklisted_opponent_pokemon = set()
|
||||
self.allowed_dexsanity_species = set()
|
||||
self.modified_maps = copy.deepcopy(emerald_data.maps)
|
||||
self.modified_species = copy.deepcopy(emerald_data.species)
|
||||
self.modified_tmhm_moves = []
|
||||
@@ -265,6 +267,7 @@ class PokemonEmeraldWorld(World):
|
||||
from .regions import create_regions
|
||||
all_regions = create_regions(self)
|
||||
|
||||
randomize_wild_encounters(self)
|
||||
# Categories with progression items always included
|
||||
categories = {
|
||||
LocationCategory.BADGE,
|
||||
@@ -494,7 +497,6 @@ class PokemonEmeraldWorld(World):
|
||||
set_rules(self)
|
||||
|
||||
def connect_entrances(self):
|
||||
randomize_wild_encounters(self)
|
||||
self.shuffle_badges_hms()
|
||||
# For entrance randomization, disconnect entrances here, randomize map, then
|
||||
# undo badge/HM placement and re-shuffle them in the new map.
|
||||
|
||||
@@ -110,7 +110,7 @@ def create_locations_by_category(world: "PokemonEmeraldWorld", regions: Dict[str
|
||||
national_dex_id = int(location_name[-3:]) # Location names are formatted POKEDEX_REWARD_###
|
||||
|
||||
# Don't create this pokedex location if player can't find it in the wild
|
||||
if NATIONAL_ID_TO_SPECIES_ID[national_dex_id] in world.blacklisted_wilds:
|
||||
if NATIONAL_ID_TO_SPECIES_ID[national_dex_id] in world.blacklisted_wilds or NATIONAL_ID_TO_SPECIES_ID[national_dex_id] not in world.allowed_dexsanity_species:
|
||||
continue
|
||||
|
||||
location_id += POKEDEX_OFFSET + national_dex_id
|
||||
|
||||
@@ -63,7 +63,7 @@ def randomize_opponent_parties(world: "PokemonEmeraldWorld") -> None:
|
||||
if len(merged_blacklist) < NUM_REAL_SPECIES:
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("This should never happen")
|
||||
merged_blacklist: Set[int] = set()
|
||||
|
||||
candidates = [
|
||||
species
|
||||
|
||||
@@ -4,7 +4,7 @@ Option definitions for Pokemon Emerald
|
||||
from dataclasses import dataclass
|
||||
|
||||
from Options import (Choice, DeathLink, DefaultOnToggle, OptionSet, NamedRange, Range, Toggle, FreeText,
|
||||
PerGameCommonOptions, OptionGroup, StartInventory)
|
||||
PerGameCommonOptions, OptionGroup, StartInventory, OptionList)
|
||||
|
||||
from .data import data
|
||||
|
||||
@@ -129,6 +129,17 @@ class Dexsanity(Toggle):
|
||||
display_name = "Dexsanity"
|
||||
|
||||
|
||||
class DexsanityEncounterTypes(OptionList):
|
||||
"""
|
||||
Determines which Dexsanity encounter areas are in logic.
|
||||
|
||||
Logic will only consider access to Pokemon at these encounter types, but they may still be found elsewhere.
|
||||
"""
|
||||
display_name = "Dexsanity Encounter Types"
|
||||
valid_keys = {"Land", "Water", "Fishing"}
|
||||
default = valid_keys.copy()
|
||||
|
||||
|
||||
class Trainersanity(Toggle):
|
||||
"""
|
||||
Defeating a trainer gives you an item.
|
||||
@@ -870,6 +881,7 @@ class PokemonEmeraldOptions(PerGameCommonOptions):
|
||||
npc_gifts: RandomizeNpcGifts
|
||||
berry_trees: RandomizeBerryTrees
|
||||
dexsanity: Dexsanity
|
||||
dexsanity_encounter_types: DexsanityEncounterTypes
|
||||
trainersanity: Trainersanity
|
||||
item_pool_type: ItemPoolType
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ def _rename_wild_events(world: "PokemonEmeraldWorld", map_data: MapData, new_slo
|
||||
for r, sc in _encounter_subcategory_ranges[encounter_type].items()
|
||||
if i in r
|
||||
)
|
||||
subcategory_species = []
|
||||
subcategory_species: list[int] = []
|
||||
for k in subcategory_range:
|
||||
if new_slots[k] not in subcategory_species:
|
||||
subcategory_species.append(new_slots[k])
|
||||
@@ -264,6 +264,12 @@ def _rename_wild_events(world: "PokemonEmeraldWorld", map_data: MapData, new_slo
|
||||
|
||||
|
||||
def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
|
||||
encounter_table = {
|
||||
"Land": EncounterType.LAND,
|
||||
"Water": EncounterType.WATER,
|
||||
"Fishing": EncounterType.FISHING,
|
||||
}
|
||||
enabled_encounters = {encounter_table[encounter_type] for encounter_type in world.options.dexsanity_encounter_types.value}
|
||||
if world.options.wild_pokemon == RandomizeWildPokemon.option_vanilla:
|
||||
return
|
||||
|
||||
@@ -278,7 +284,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
|
||||
RandomizeWildPokemon.option_match_base_stats_and_type,
|
||||
}
|
||||
|
||||
already_placed = set()
|
||||
already_placed: set[int] = set()
|
||||
num_placeable_species = NUM_REAL_SPECIES - len(world.blacklisted_wilds)
|
||||
|
||||
priority_species = [data.constants["SPECIES_WAILORD"], data.constants["SPECIES_RELICANTH"]]
|
||||
@@ -349,7 +355,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
|
||||
if len(merged_blacklist) < NUM_REAL_SPECIES:
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("This should never happen")
|
||||
merged_blacklist = set()
|
||||
|
||||
candidates = [
|
||||
species
|
||||
@@ -365,11 +371,13 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
|
||||
species_old_to_new_map[species_id] = new_species_id
|
||||
|
||||
if world.options.dexsanity and encounter_type != EncounterType.ROCK_SMASH \
|
||||
and map_name not in OUT_OF_LOGIC_MAPS:
|
||||
and map_name not in OUT_OF_LOGIC_MAPS and new_species_id not in world.blacklisted_wilds:
|
||||
already_placed.add(new_species_id)
|
||||
|
||||
# Actually create the new list of slots and encounter table
|
||||
new_slots: List[int] = []
|
||||
if encounter_type in enabled_encounters:
|
||||
world.allowed_dexsanity_species.update(table.slots)
|
||||
for species_id in table.slots:
|
||||
new_slots.append(species_old_to_new_map[species_id])
|
||||
|
||||
|
||||
@@ -1548,7 +1548,7 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
|
||||
for i in range(NUM_REAL_SPECIES):
|
||||
species = data.species[NATIONAL_ID_TO_SPECIES_ID[i + 1]]
|
||||
|
||||
if species.species_id in world.blacklisted_wilds:
|
||||
if species.species_id in world.blacklisted_wilds or species.species_id not in world.allowed_dexsanity_species:
|
||||
continue
|
||||
|
||||
set_rule(
|
||||
|
||||
@@ -4,7 +4,10 @@ from .items import RiskOfRainItem, item_table, item_pool_weights, offset, filler
|
||||
from .locations import RiskOfRainLocation, item_pickups, get_locations
|
||||
from .rules import set_rules
|
||||
from .ror2environments import environment_vanilla_table, environment_vanilla_orderedstages_table, \
|
||||
environment_sotv_orderedstages_table, environment_sotv_table, collapse_dict_list_vertical, shift_by_offset
|
||||
environment_sotv_orderedstages_table, environment_sotv_table, environment_sost_orderedstages_table, \
|
||||
environment_sost_table, collapse_dict_list_vertical, shift_by_offset, environment_vanilla_variants_table, \
|
||||
environment_vanilla_variant_orderedstages_table, environment_sots_variants_table, \
|
||||
environment_sots_variants_orderedstages_table
|
||||
|
||||
from BaseClasses import Item, ItemClassification, Tutorial
|
||||
from .options import ItemWeights, ROR2Options, ror2_option_groups
|
||||
@@ -46,7 +49,7 @@ class RiskOfRainWorld(World):
|
||||
}
|
||||
location_name_to_id = item_pickups
|
||||
|
||||
required_client_version = (0, 5, 0)
|
||||
required_client_version = (0, 6, 4)
|
||||
web = RiskOfWeb()
|
||||
total_revivals: int
|
||||
|
||||
@@ -62,7 +65,9 @@ class RiskOfRainWorld(World):
|
||||
scavengers=self.options.scavengers_per_stage.value,
|
||||
scanners=self.options.scanner_per_stage.value,
|
||||
altars=self.options.altars_per_stage.value,
|
||||
dlc_sotv=bool(self.options.dlc_sotv.value)
|
||||
dlc_sotv=bool(self.options.dlc_sotv.value),
|
||||
dlc_sots=bool(self.options.dlc_sots.value),
|
||||
stage_variants=bool(self.options.stage_variants)
|
||||
)
|
||||
)
|
||||
self.total_revivals = int(self.options.total_revivals.value / 100 *
|
||||
@@ -71,6 +76,8 @@ class RiskOfRainWorld(World):
|
||||
self.total_revivals -= 1
|
||||
if self.options.victory == "voidling" and not self.options.dlc_sotv:
|
||||
self.options.victory.value = self.options.victory.option_any
|
||||
if self.options.victory == "falseson" and not self.options.dlc_sots:
|
||||
self.options.victory.value = self.options.victory.option_any
|
||||
|
||||
def create_regions(self) -> None:
|
||||
|
||||
@@ -105,16 +112,39 @@ class RiskOfRainWorld(World):
|
||||
|
||||
# figure out all available ordered stages for each tier
|
||||
environment_available_orderedstages_table = environment_vanilla_orderedstages_table
|
||||
environments_pool = shift_by_offset(environment_vanilla_table, environment_offset)
|
||||
# Vanilla Variants
|
||||
if self.options.stage_variants:
|
||||
environment_available_orderedstages_table = \
|
||||
collapse_dict_list_vertical(environment_available_orderedstages_table,
|
||||
environment_vanilla_variant_orderedstages_table)
|
||||
if self.options.dlc_sotv:
|
||||
environment_available_orderedstages_table = \
|
||||
collapse_dict_list_vertical(environment_available_orderedstages_table,
|
||||
environment_sotv_orderedstages_table)
|
||||
if self.options.dlc_sots:
|
||||
environment_available_orderedstages_table = \
|
||||
collapse_dict_list_vertical(environment_available_orderedstages_table,
|
||||
environment_sost_orderedstages_table)
|
||||
if self.options.dlc_sots and self.options.stage_variants:
|
||||
environment_available_orderedstages_table = \
|
||||
collapse_dict_list_vertical(environment_available_orderedstages_table,
|
||||
environment_sots_variants_orderedstages_table)
|
||||
|
||||
environments_pool = shift_by_offset(environment_vanilla_table, environment_offset)
|
||||
|
||||
if self.options.stage_variants:
|
||||
environment_offset_table = shift_by_offset(environment_vanilla_variants_table, environment_offset)
|
||||
environments_pool = {**environments_pool, **environment_offset_table}
|
||||
if self.options.dlc_sotv:
|
||||
environment_offset_table = shift_by_offset(environment_sotv_table, environment_offset)
|
||||
environments_pool = {**environments_pool, **environment_offset_table}
|
||||
if self.options.dlc_sots:
|
||||
environment_offset_table = shift_by_offset(environment_sost_table, environment_offset)
|
||||
environments_pool = {**environments_pool, **environment_offset_table}
|
||||
# SOTS Variant Environments
|
||||
if self.options.dlc_sots and self.options.stage_variants:
|
||||
environment_offset_table = shift_by_offset(environment_sots_variants_table, environment_offset)
|
||||
environments_pool = {**environments_pool, **environment_offset_table}
|
||||
|
||||
# percollect starting environment for stage 1
|
||||
unlock = self.random.choices(list(environment_available_orderedstages_table[0].keys()), k=1)
|
||||
self.multiworld.push_precollected(self.create_item(unlock[0]))
|
||||
@@ -146,7 +176,9 @@ class RiskOfRainWorld(World):
|
||||
scavengers=self.options.scavengers_per_stage.value,
|
||||
scanners=self.options.scanner_per_stage.value,
|
||||
altars=self.options.altars_per_stage.value,
|
||||
dlc_sotv=bool(self.options.dlc_sotv.value)
|
||||
dlc_sotv=bool(self.options.dlc_sotv.value),
|
||||
dlc_sots=bool(self.options.dlc_sots.value),
|
||||
stage_variants=bool(self.options.stage_variants)
|
||||
)
|
||||
)
|
||||
# Create junk items
|
||||
@@ -223,7 +255,7 @@ class RiskOfRainWorld(World):
|
||||
"chests_per_stage", "shrines_per_stage", "scavengers_per_stage",
|
||||
"scanner_per_stage", "altars_per_stage", "total_revivals",
|
||||
"start_with_revive", "final_stage_death", "death_link", "require_stages",
|
||||
"progressive_stages", casing="camel")
|
||||
"progressive_stages", "stage_variants", "show_seer_portals", casing="camel")
|
||||
return {
|
||||
**options_dict,
|
||||
"seed": "".join(self.random.choice(string.digits) for _ in range(16)),
|
||||
@@ -254,7 +286,7 @@ class RiskOfRainWorld(World):
|
||||
event_loc.place_locked_item(RiskOfRainItem("Stage 5", ItemClassification.progression, None, self.player))
|
||||
event_loc.show_in_spoiler = False
|
||||
event_region.locations.append(event_loc)
|
||||
event_loc.access_rule = lambda state: state.has("Sky Meadow", self.player)
|
||||
event_loc.access_rule = lambda state: state.has("Sky Meadow", self.player) or state.has("Helminth Hatchery", self.player)
|
||||
|
||||
victory_region = self.multiworld.get_region("Victory", self.player)
|
||||
victory_event = RiskOfRainLocation(self.player, "Victory", None, victory_region)
|
||||
|
||||
6
worlds/ror2/archipelago.json
Normal file
6
worlds/ror2/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Risk of Rain 2",
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "1.5.0",
|
||||
"authors": ["Kindasneaki"]
|
||||
}
|
||||
@@ -88,12 +88,21 @@ Explore Mode items are:
|
||||
* `Commencement`
|
||||
* `All the Hidden Realms`
|
||||
|
||||
Dlc_Sotv items
|
||||
DLC Survivors of the Void (SOTV) items
|
||||
* `Siphoned Forest`
|
||||
* `Aphelian Sanctuary`
|
||||
* `Sulfur Pools`
|
||||
* `Void Locus`
|
||||
|
||||
DLC Seekers of the Storm (SOTS) items
|
||||
|
||||
* `Shattered Abodes`, `Vicious Falls`, `Disturbed Impact`
|
||||
* `Reformed Altar`
|
||||
* `Treeborn Colony`, `Golden Dieback`
|
||||
* `Prime Meridian`
|
||||
* `Helminth Hatchery`
|
||||
|
||||
|
||||
When an explore item is granted, it will unlock that environment and will now be accessible! The
|
||||
game will still pick randomly which environment is next, but it will first check to see if they are available. If you have
|
||||
multiple of the next environments unlocked, it will weight the game to have a ***higher chance*** to go to one you
|
||||
|
||||
@@ -23,6 +23,13 @@ all necessary dependencies as well.
|
||||
|
||||
Click on the `Start modded` button in the top left in `r2modman` to start the game with the Archipelago mod installed.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
* The mod doesn't show up in game!
|
||||
* `r2modman` looks for the game at its default directory. If you have the game installed somewhere else,
|
||||
you can update `r2modman` by going to `Settings > Change Risk of Rain 2 folder`
|
||||
and selecting the correct directory.
|
||||
|
||||
## Configuring your YAML File
|
||||
### What is a YAML and why do I need one?
|
||||
You can see the [basic multiworld setup guide](/tutorial/Archipelago/setup/en) here on the Archipelago website to learn
|
||||
@@ -59,6 +66,7 @@ also optionally connect to the multiworld using the text client, which can be fo
|
||||
|
||||
### In-Game Commands
|
||||
These commands are to be used in-game by using ``Ctrl + Alt + ` `` and then typing the following:
|
||||
- `archipelago_reconnect` Reconnect to AP.
|
||||
- `archipelago_connect <url> <port> <slot> [password]` example: "archipelago_connect archipelago.gg 38281 SlotName".
|
||||
- `archipelago_deathlink true/false` Toggle deathlink.
|
||||
- `archipelago_disconnect` Disconnect from AP.
|
||||
|
||||
@@ -3,7 +3,8 @@ from BaseClasses import Location
|
||||
from .options import TotalLocations, ChestsPerEnvironment, ShrinesPerEnvironment, ScavengersPerEnvironment, \
|
||||
ScannersPerEnvironment, AltarsPerEnvironment
|
||||
from .ror2environments import compress_dict_list_horizontal, environment_vanilla_orderedstages_table, \
|
||||
environment_sotv_orderedstages_table
|
||||
environment_sotv_orderedstages_table, environment_sost_orderedstages_table, \
|
||||
environment_sots_variants_orderedstages_table, environment_vanilla_variant_orderedstages_table
|
||||
|
||||
|
||||
class RiskOfRainLocation(Location):
|
||||
@@ -57,13 +58,20 @@ def get_environment_locations(chests: int, shrines: int, scavengers: int, scanne
|
||||
return locations
|
||||
|
||||
|
||||
def get_locations(chests: int, shrines: int, scavengers: int, scanners: int, altars: int, dlc_sotv: bool) \
|
||||
def get_locations(chests: int, shrines: int, scavengers: int, scanners: int, altars: int, dlc_sotv: bool,
|
||||
dlc_sots: bool, stage_variants: bool) \
|
||||
-> Dict[str, int]:
|
||||
"""Get a dictionary of locations for the orderedstage environments with the locations from the parameters."""
|
||||
locations = {}
|
||||
orderedstages = compress_dict_list_horizontal(environment_vanilla_orderedstages_table)
|
||||
if stage_variants:
|
||||
orderedstages.update(compress_dict_list_horizontal(environment_vanilla_variant_orderedstages_table))
|
||||
if dlc_sotv:
|
||||
orderedstages.update(compress_dict_list_horizontal(environment_sotv_orderedstages_table))
|
||||
if dlc_sots:
|
||||
orderedstages.update(compress_dict_list_horizontal(environment_sost_orderedstages_table))
|
||||
if dlc_sots and stage_variants:
|
||||
orderedstages.update(compress_dict_list_horizontal(environment_sots_variants_orderedstages_table))
|
||||
# for every environment, generate the respective locations
|
||||
for environment_name, environment_index in orderedstages.items():
|
||||
locations.update(get_environment_locations(
|
||||
@@ -86,4 +94,6 @@ location_table.update(get_locations(
|
||||
scanners=ScannersPerEnvironment.range_end,
|
||||
altars=AltarsPerEnvironment.range_end,
|
||||
dlc_sotv=True,
|
||||
dlc_sots=True,
|
||||
stage_variants=True
|
||||
))
|
||||
|
||||
@@ -22,8 +22,9 @@ class Goal(Choice):
|
||||
class Victory(Choice):
|
||||
"""
|
||||
Mithrix: Defeat Mithrix in Commencement
|
||||
Voidling: Defeat the Voidling in The Planetarium (DLC required! Will select any if not enabled.)
|
||||
Voidling: Defeat the Voidling in The Planetarium (SOTV DLC required! Will select any if not enabled.)
|
||||
Limbo: Defeat the Scavenger in Hidden Realm: A Moment, Whole
|
||||
Falseson: Defeat False son and gift an item to the altar in Prime Meridian (SOTS DLC required! Will select any if not enabled.)
|
||||
Any: Any victory in the game will count. See Final Stage Death for additional ways.
|
||||
"""
|
||||
display_name = "Victory Condition"
|
||||
@@ -31,6 +32,7 @@ class Victory(Choice):
|
||||
option_mithrix = 1
|
||||
option_voidling = 2
|
||||
option_limbo = 3
|
||||
option_falseson = 4
|
||||
default = 0
|
||||
|
||||
|
||||
@@ -138,18 +140,26 @@ class FinalStageDeath(Toggle):
|
||||
If not use the following to tell if final stage death will count:
|
||||
Victory: mithrix - only dying in Commencement will count.
|
||||
Victory: voidling - only dying in The Planetarium will count.
|
||||
Victory: limbo - Obliterating yourself will count."""
|
||||
Victory: limbo - Obliterating yourself will count.
|
||||
Victory: falseson - only dying in Prime Meridian will count."""
|
||||
display_name = "Final Stage Death is Win"
|
||||
|
||||
|
||||
class DLC_SOTV(Toggle):
|
||||
"""
|
||||
Enable if you are using SOTV DLC.
|
||||
Enable if you are using Survivors of the Void DLC.
|
||||
Affects environment availability for Explore Mode.
|
||||
Adds Void Items into the item pool
|
||||
"""
|
||||
display_name = "Enable DLC - SOTV"
|
||||
|
||||
class DLC_SOTS(Toggle):
|
||||
"""
|
||||
Enable if you are using Seekers of the Storm DLC.
|
||||
Affects environment availability for Explore Mode.
|
||||
"""
|
||||
display_name = "Enable DLC - SOTS"
|
||||
|
||||
|
||||
class RequireStages(DefaultOnToggle):
|
||||
"""Add Stage items to the pool to block access to the next set of environments."""
|
||||
@@ -162,6 +172,23 @@ class ProgressiveStages(DefaultOnToggle):
|
||||
display_name = "Progressive Stages"
|
||||
|
||||
|
||||
class StageVariants(Toggle):
|
||||
"""Enable if you want to include stage variants in the environment pool.
|
||||
Stages included are:
|
||||
- Distant Roost (2)
|
||||
- Titanic Plains (2)
|
||||
SOTS DLC Enabled:
|
||||
- Vicious Falls
|
||||
- Shattered Abodes
|
||||
- Golden Dieback"""
|
||||
display_name = "Include Stage Variants"
|
||||
|
||||
|
||||
class ShowSeerPortals(DefaultOnToggle):
|
||||
"""Shows Seer Portals at the teleporter to allow choosing the next environment."""
|
||||
display_name = "Show Seer Portals"
|
||||
|
||||
|
||||
class GreenScrap(Range):
|
||||
"""Weight of Green Scraps in the item pool.
|
||||
|
||||
@@ -384,6 +411,8 @@ ror2_option_groups = [
|
||||
AltarsPerEnvironment,
|
||||
RequireStages,
|
||||
ProgressiveStages,
|
||||
StageVariants,
|
||||
ShowSeerPortals,
|
||||
]),
|
||||
OptionGroup("Classic Mode Options", [
|
||||
TotalLocations,
|
||||
@@ -427,8 +456,11 @@ class ROR2Options(PerGameCommonOptions):
|
||||
start_with_revive: StartWithRevive
|
||||
final_stage_death: FinalStageDeath
|
||||
dlc_sotv: DLC_SOTV
|
||||
dlc_sots: DLC_SOTS
|
||||
require_stages: RequireStages
|
||||
progressive_stages: ProgressiveStages
|
||||
stage_variants: StageVariants
|
||||
show_seer_portals: ShowSeerPortals
|
||||
death_link: DeathLink
|
||||
item_pickup_step: ItemPickupStep
|
||||
shrine_use_step: ShrineUseStep
|
||||
|
||||
@@ -18,13 +18,10 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None:
|
||||
multiworld = ror2_world.multiworld
|
||||
# Default Locations
|
||||
non_dlc_regions: Dict[str, RoRRegionData] = {
|
||||
"Menu": RoRRegionData(None, ["Distant Roost", "Distant Roost (2)",
|
||||
"Titanic Plains", "Titanic Plains (2)",
|
||||
"Menu": RoRRegionData(None, ["Distant Roost", "Titanic Plains",
|
||||
"Verdant Falls"]),
|
||||
"Distant Roost": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Distant Roost (2)": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Titanic Plains": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Titanic Plains (2)": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Verdant Falls": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Abandoned Aqueduct": RoRRegionData([], ["OrderedStage_2"]),
|
||||
"Wetland Aspect": RoRRegionData([], ["OrderedStage_2"]),
|
||||
@@ -35,12 +32,30 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None:
|
||||
"Sundered Grove": RoRRegionData([], ["OrderedStage_4"]),
|
||||
"Sky Meadow": RoRRegionData([], ["Hidden Realm: Bulwark's Ambry", "OrderedStage_5"]),
|
||||
}
|
||||
non_dlc_variant_regions: Dict[str, RoRRegionData] = {
|
||||
"Distant Roost (2)": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Titanic Plains (2)": RoRRegionData([], ["OrderedStage_1"]),
|
||||
}
|
||||
# SOTV Regions
|
||||
dlc_regions: Dict[str, RoRRegionData] = {
|
||||
dlc_sotv_regions: Dict[str, RoRRegionData] = {
|
||||
"Siphoned Forest": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Aphelian Sanctuary": RoRRegionData([], ["OrderedStage_2"]),
|
||||
"Sulfur Pools": RoRRegionData([], ["OrderedStage_3"])
|
||||
}
|
||||
|
||||
dlc_sost_regions: Dict[str, RoRRegionData] = {
|
||||
"Shattered Abodes": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Reformed Altar": RoRRegionData([], ["OrderedStage_2", "Treeborn Colony"]),
|
||||
"Treeborn Colony": RoRRegionData([], ["OrderedStage_3", "Prime Meridian"]),
|
||||
"Helminth Hatchery": RoRRegionData([], ["Hidden Realm: Bulwark's Ambry", "OrderedStage_5"]),
|
||||
}
|
||||
|
||||
dlc_sots_variant_regions: Dict[str, RoRRegionData] = {
|
||||
"Viscous Falls": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Disturbed Impact": RoRRegionData([], ["OrderedStage_1"]),
|
||||
"Golden Dieback": RoRRegionData([], ["OrderedStage_3", "Prime Meridian"]),
|
||||
}
|
||||
|
||||
other_regions: Dict[str, RoRRegionData] = {
|
||||
"Commencement": RoRRegionData(None, ["Victory", "Petrichor V"]),
|
||||
"OrderedStage_5": RoRRegionData(None, ["Hidden Realm: A Moment, Fractured",
|
||||
@@ -61,10 +76,15 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None:
|
||||
"Hidden Realm: Bazaar Between Time": RoRRegionData(None, ["Void Fields"]),
|
||||
"Hidden Realm: Gilded Coast": RoRRegionData(None, None)
|
||||
}
|
||||
dlc_other_regions: Dict[str, RoRRegionData] = {
|
||||
dlc_sotv_other_regions: Dict[str, RoRRegionData] = {
|
||||
"The Planetarium": RoRRegionData(None, ["Victory", "Petrichor V"]),
|
||||
"Void Locus": RoRRegionData(None, ["The Planetarium"])
|
||||
}
|
||||
|
||||
dlc_sost_other_regions: Dict[str, RoRRegionData] = {
|
||||
"Prime Meridian": RoRRegionData(None, ["Victory", "Petrichor V"]),
|
||||
}
|
||||
|
||||
# Totals of each item
|
||||
chests = int(ror2_options.chests_per_stage)
|
||||
shrines = int(ror2_options.shrines_per_stage)
|
||||
@@ -72,8 +92,14 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None:
|
||||
scanners = int(ror2_options.scanner_per_stage)
|
||||
newt = int(ror2_options.altars_per_stage)
|
||||
all_location_regions = {**non_dlc_regions}
|
||||
if ror2_options.stage_variants:
|
||||
all_location_regions.update(non_dlc_variant_regions)
|
||||
if ror2_options.dlc_sotv:
|
||||
all_location_regions = {**non_dlc_regions, **dlc_regions}
|
||||
all_location_regions.update(dlc_sotv_regions)
|
||||
if ror2_options.dlc_sots:
|
||||
all_location_regions.update(dlc_sost_regions)
|
||||
if ror2_options.dlc_sots and ror2_options.stage_variants:
|
||||
all_location_regions.update(dlc_sots_variant_regions)
|
||||
|
||||
# Locations
|
||||
for key in all_location_regions:
|
||||
@@ -99,25 +125,52 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None:
|
||||
all_location_regions[key].locations.append(f"{key}: Newt Altar {i + 1}")
|
||||
regions_pool: Dict = {**all_location_regions, **other_regions}
|
||||
|
||||
# DLC Locations
|
||||
# Non DLC Variant Locations
|
||||
if ror2_options.stage_variants:
|
||||
non_dlc_regions["Menu"].region_exits.append("Distant Roost (2)")
|
||||
non_dlc_regions["Menu"].region_exits.append("Titanic Plains (2)")
|
||||
# SOTV DLC Locations
|
||||
if ror2_options.dlc_sotv:
|
||||
non_dlc_regions["Menu"].region_exits.append("Siphoned Forest")
|
||||
other_regions["OrderedStage_1"].region_exits.append("Aphelian Sanctuary")
|
||||
other_regions["OrderedStage_2"].region_exits.append("Sulfur Pools")
|
||||
other_regions["Void Fields"].region_exits.append("Void Locus")
|
||||
other_regions["Commencement"].region_exits.append("The Planetarium")
|
||||
regions_pool: Dict = {**all_location_regions, **other_regions, **dlc_other_regions}
|
||||
|
||||
# SOTS DLC Locations
|
||||
if ror2_options.dlc_sots:
|
||||
non_dlc_regions["Menu"].region_exits.append("Shattered Abodes")
|
||||
other_regions["OrderedStage_1"].region_exits.append("Reformed Altar")
|
||||
other_regions["OrderedStage_4"].region_exits.append("Helminth Hatchery")
|
||||
|
||||
# SOTS Variant Locations
|
||||
if ror2_options.dlc_sots and ror2_options.stage_variants:
|
||||
non_dlc_regions["Menu"].region_exits.append("Viscous Falls")
|
||||
non_dlc_regions["Menu"].region_exits.append("Disturbed Impact")
|
||||
dlc_sost_regions["Reformed Altar"].region_exits.append("Golden Dieback")
|
||||
|
||||
if ror2_options.dlc_sotv:
|
||||
regions_pool.update(dlc_sotv_other_regions)
|
||||
if ror2_options.dlc_sots:
|
||||
regions_pool.update(dlc_sost_other_regions)
|
||||
|
||||
# Check to see if Victory needs to be removed from regions
|
||||
if ror2_options.victory == "mithrix":
|
||||
other_regions["Hidden Realm: A Moment, Whole"].region_exits.pop(0)
|
||||
dlc_other_regions["The Planetarium"].region_exits.pop(0)
|
||||
dlc_sotv_other_regions["The Planetarium"].region_exits.pop(0)
|
||||
dlc_sost_other_regions["Prime Meridian"].region_exits.pop(0)
|
||||
elif ror2_options.victory == "voidling":
|
||||
other_regions["Commencement"].region_exits.pop(0)
|
||||
other_regions["Hidden Realm: A Moment, Whole"].region_exits.pop(0)
|
||||
dlc_sost_other_regions["Prime Meridian"].region_exits.pop(0)
|
||||
elif ror2_options.victory == "limbo":
|
||||
other_regions["Commencement"].region_exits.pop(0)
|
||||
dlc_other_regions["The Planetarium"].region_exits.pop(0)
|
||||
dlc_sotv_other_regions["The Planetarium"].region_exits.pop(0)
|
||||
dlc_sost_other_regions["Prime Meridian"].region_exits.pop(0)
|
||||
elif ror2_options.victory == "falseson":
|
||||
other_regions["Commencement"].region_exits.pop(0)
|
||||
other_regions["Hidden Realm: A Moment, Whole"].region_exits.pop(0)
|
||||
dlc_sotv_other_regions["The Planetarium"].region_exits.pop(0)
|
||||
|
||||
# Create all the regions
|
||||
for name, data in regions_pool.items():
|
||||
|
||||
@@ -4,11 +4,14 @@ from typing import Dict, List, TypeVar
|
||||
|
||||
environment_vanilla_orderedstage_1_table: Dict[str, int] = {
|
||||
"Distant Roost": 7, # blackbeach
|
||||
"Distant Roost (2)": 8, # blackbeach2
|
||||
"Titanic Plains": 15, # golemplains
|
||||
"Titanic Plains (2)": 16, # golemplains2
|
||||
"Verdant Falls": 28, # lakes
|
||||
}
|
||||
environment_vanilla_variant_orderedstage_1_table: Dict[str, int] = {
|
||||
"Distant Roost (2)": 8, # blackbeach2
|
||||
"Titanic Plains (2)": 16, # golemplains2
|
||||
}
|
||||
|
||||
environment_vanilla_orderedstage_2_table: Dict[str, int] = {
|
||||
"Abandoned Aqueduct": 17, # goolake
|
||||
"Wetland Aspect": 12, # foggyswamp
|
||||
@@ -54,6 +57,34 @@ environment_sotv_special_table: Dict[str, int] = {
|
||||
"The Planetarium": 45, # voidraid
|
||||
}
|
||||
|
||||
environment_sost_orderstage_1_table: Dict[str, int] = {
|
||||
"Shattered Abodes": 54, # village
|
||||
|
||||
}
|
||||
environment_sost_variant_orderstage_1_table: Dict[str, int] = {
|
||||
"Viscous Falls": 34, # lakesnight
|
||||
"Disturbed Impact": 55, # villagenight
|
||||
}
|
||||
|
||||
environment_sost_orderstage_2_table: Dict[str, int] = {
|
||||
"Reformed Altar": 36, # lemuriantemple
|
||||
}
|
||||
|
||||
environment_sost_orderstage_3_table: Dict[str, int] = {
|
||||
"Treeborn Colony": 21, # habitat
|
||||
}
|
||||
environment_sost_variant_orderstage_3_table: Dict[str, int] = {
|
||||
"Golden Dieback": 22, # habitatfall
|
||||
}
|
||||
|
||||
environment_sost_orderstage_5_table: Dict[str, int] = {
|
||||
"Helminth Hatchery": 23, # helminthroost
|
||||
}
|
||||
|
||||
environment_sost_special_table: Dict[str, int] = {
|
||||
"Prime Meridian": 40, # meridian
|
||||
}
|
||||
|
||||
X = TypeVar("X")
|
||||
Y = TypeVar("Y")
|
||||
|
||||
@@ -100,18 +131,32 @@ environment_vanilla_orderedstages_table = \
|
||||
environment_vanilla_table = \
|
||||
{**compress_dict_list_horizontal(environment_vanilla_orderedstages_table),
|
||||
**environment_vanilla_hidden_realm_table, **environment_vanilla_special_table}
|
||||
# Vanilla Variants
|
||||
environment_vanilla_variant_orderedstages_table = \
|
||||
[environment_vanilla_variant_orderedstage_1_table]
|
||||
environment_vanilla_variants_table = \
|
||||
{**compress_dict_list_horizontal(environment_vanilla_variant_orderedstages_table)}
|
||||
|
||||
# SoTV
|
||||
environment_sotv_orderedstages_table = \
|
||||
[environment_sotv_orderedstage_1_table, environment_sotv_orderedstage_2_table,
|
||||
environment_sotv_orderedstage_3_table]
|
||||
environment_sotv_table = \
|
||||
{**compress_dict_list_horizontal(environment_sotv_orderedstages_table), **environment_sotv_special_table}
|
||||
# SoST
|
||||
environment_sost_orderedstages_table = \
|
||||
[environment_sost_orderstage_1_table, environment_sost_orderstage_2_table,
|
||||
environment_sost_orderstage_3_table, {}, environment_sost_orderstage_5_table] # There is no new stage 4 in SoST
|
||||
environment_sost_table = \
|
||||
{**compress_dict_list_horizontal(environment_sost_orderedstages_table), **environment_sost_special_table}
|
||||
# SOTS Variants
|
||||
environment_sots_variants_orderedstages_table = \
|
||||
[environment_sost_variant_orderstage_1_table, {}, environment_sost_variant_orderstage_3_table]
|
||||
environment_sots_variants_table = \
|
||||
{**compress_dict_list_horizontal(environment_sots_variants_orderedstages_table)}
|
||||
|
||||
environment_non_orderedstages_table = \
|
||||
{**environment_vanilla_hidden_realm_table, **environment_vanilla_special_table, **environment_sotv_special_table}
|
||||
environment_orderedstages_table = \
|
||||
collapse_dict_list_vertical(environment_vanilla_orderedstages_table, environment_sotv_orderedstages_table)
|
||||
environment_all_table = {**environment_vanilla_table, **environment_sotv_table}
|
||||
environment_all_table = {**environment_vanilla_table, **environment_sotv_table, **environment_sost_table,
|
||||
**environment_vanilla_variants_table, **environment_sots_variants_table}
|
||||
|
||||
|
||||
def shift_by_offset(dictionary: Dict[str, int], offset: int) -> Dict[str, int]:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user