Compare commits

..

108 Commits

Author SHA1 Message Date
black-sliver
e8dc0dc592 Merge remote-tracking branch 'imurx/custom-port-range' into active/rc-site 2026-03-10 22:05:58 +01:00
black-sliver
e8f014fcc8 Merge branch 'feat/data-package-cache' into active/rc-site 2026-03-10 22:00:09 +01:00
black-sliver
89085ea7b8 Mark WebHost as beta 2026-03-10 21:56:50 +01:00
Ixrec
03b638d027 Docs: Reword 'could be generated from json' to avoid encouraging slow world loads (#5960) 2026-03-10 20:49:47 +01:00
Exempt-Medic
3c802d03a1 DS3: Use remaining_fill instead of custom fill (#4397)
---------

Co-authored-by: Mysteryem <Mysteryem@users.noreply.github.com>
2026-03-10 20:11:23 +01:00
Mysteryem
a8e926a1a9 Core: Make Generic ER only consider the current world in isolation (#4680) 2026-03-10 20:08:20 +01:00
Rjosephson
56c2272bfd RoR2: Seekers of the Storm (SOTS) DLC Support (#5569) 2026-03-10 20:05:59 +01:00
Fabian Dill
47e581bc30 LttP: add manifest (#6005) 2026-03-10 20:04:27 +01:00
black-sliver
3235863f2e WebHost: add stats show cli command (#5995)
Usage: flask -A "WebHost:get_app()" stats show

Currently only shows sum and top10 biggest games packages.
2026-03-10 19:57:04 +01:00
black-sliver
f00d29e072 Tests: fix race in test hosting shutdown (#5987) 2026-03-10 19:56:23 +01:00
Gryphonlady
d000c0f265 Docs: Update plando_en.md with item group example (#6024)
* Update plando_en.md with item group example

Added example YAML block for item placement using an item group, including recommendation of use of `true` value with item groups to avoid unintended behaviors, with an example of the same.  Adjustments more than welcome!

* Made clarifying revision to description of Generator handling of item groups

Clarified the behavior of the Generator regarding item creation when item groups are used in plando.
2026-03-10 19:23:25 +01:00
Duck
94136ac223 Docs: Add references to running from source (#6022) 2026-03-10 19:18:03 +01:00
LeonarthCG
72ff9b1a7d Saving Princess: Security fixes for issues detected by Bandit (#6013)
* Saving Princess: absolute paths on suprocess.run

* Saving Princess: more error handling for downloads

* Saving Princess: rework launch_command setting

Apparently subprocess.Popen requires a list for args instead of a string everywhere but in Windows, so the change was preventing the game from running on Linux. Additionally, the game is now launched using absolute paths.

* Saving Princess: prevent bandit warnings

* Saving Princess: remove unnecessary compare_digest

* Saving Princess: fix Linux paths by using which

* Saving Princess: rename launch command setting

Previously, launch_command held a string. Now it holds a list of strings. Additionally, the defaults have changed.
To prevent the wrong type from being used, the setting has been renamed, effectively abandoning the original launch_command setting.

* Saving Princess: fix Linux default command return type

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-10 18:12:48 +00:00
josephwhite
4b37283d22 WebHost: Update UTC datetime usage (timezone-naive) (#4906) 2026-03-10 18:57:48 +01:00
Scipio Wright
c3659fb3ef TUNIC: Refactor entrance hint generation (#5620)
* Refactor hint generation

* Remove debug print

* Early out per qwint's comment
2026-03-10 18:55:07 +01:00
Matthew Wells
1a8a71f593 Dark Souls 3: Update location descriptions for Red Tearstone Ring and Hood of Prayer (#5602)
RTSR's description was incorrect and Hood of Prayer was missing its description
2026-03-10 18:54:24 +01:00
Goo-Dang
c255ea8fc6 Pokemon Emerald: Dexsanity Encounter Type Option (#6016)
---------

Co-authored-by: Bryce Wilson <gyroscope15@gmail.com>
2026-03-10 18:52:59 +01:00
Remy Jette
fd81553420 Fix missing } in example_nginx.conf (#6027) 2026-03-10 10:38:02 +00:00
Justus Lind
2c279cef09 Muse Dash: Adds 3 new music packs plus fixes being able to roll songs without a legal way to obtain them (#5698) 2026-03-10 06:11:34 +01:00
josephwhite
07a1ec0a1d Test: Defaults for Options test (#5428) 2026-03-10 05:23:26 +01:00
Jérémie Bolduc
0b6ba103c5 The Messenger: Universal Tracker support (#5344) 2026-03-10 04:56:05 +01:00
Uriel
d57b3078b5 use generator expressions 2026-03-09 16:41:17 -03:00
Uriel
baad3ceede Update WebHostLib/customserver.py
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2026-03-09 16:33:54 -03:00
Uriel
bd3686597f remove unused import 2026-03-09 13:56:14 -03:00
Uriel
805b978403 Apply suggestions from code review
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2026-03-09 13:55:34 -03:00
Star Rauchenberger
123e1f5d95 Lingo: Fix logic for Near Eight Painting (#6014) 2026-03-09 14:13:45 +01:00
Uriel
aff006a85f Update WebHostLib/customserver.py
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-09 07:00:44 -03:00
Uriel
1748048b44 Apply suggestions from code review
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-09 05:09:05 -03:00
Remy Jette
44e424362e Docs: Don't serve non-static files in example_nginx.conf (#5971)
* Docs: Don't serve non-static files in example_nginx.conf

`try_files` will serve the file as long as it exists, which means I could `GET /autolauncher.py` and be served the file.

* Use root instead of alias, add route for favicon

* Update deploy/example_nginx.conf
2026-03-09 08:51:26 +01:00
Uriel
8421ccce12 reduce range on macOS 2026-03-09 04:26:18 -03:00
Uriel
f76ea191c1 make the range lesser for port test 2026-03-09 03:26:48 -03:00
Uriel
f81e2fdf73 simplify parse game port tests to one assertListEqual 2026-03-09 00:32:25 -03:00
Uriel
07e2381cbb try to prevent busy-looping on create random port socket when doing test 2026-03-08 21:01:19 -03:00
Noa Aarts
371db53371 Stardew Valley: morel doesn't spawn in fall secret woods (#6003) 2026-03-08 21:50:34 +01:00
Silvris
5b99118dda Mega Man 3: Implement new game (#5237) 2026-03-08 21:42:06 +01:00
Star Rauchenberger
4bb6cac7c4 Lingo: Add archipelago.json (#6017) 2026-03-08 21:35:12 +01:00
LeonarthCG
99601ccebc Saving Princess: add manifest (#6008) 2026-03-08 21:34:51 +01:00
josephwhite
53956b7d4d OOT: UTC deprecation warning fix (#5983) 2026-03-08 21:34:19 +01:00
GodlFire
b38548f89b Shivers: Adds Manifest File (#5918) 2026-03-08 21:33:16 +01:00
Bryce Wilson
a8ac828241 Pokemon Emerald: Fix rare fuzzer errors (#5914) 2026-03-08 21:32:40 +01:00
StripesOO7
fc2cb3c961 OoT: change setup-guides to have 2.10 be the minimum version recommended (#5799) 2026-03-08 21:31:48 +01:00
Rosalie
9efcba5323 FF1: Added manifest (#5911) 2026-03-08 21:31:04 +01:00
jamesbrq
9f29859810 MLSS: Fix client auto-connect bug + Client cleanup (#5895) 2026-03-08 21:30:18 +01:00
Suyooo
366fd3712a MM2: Fix /request command help (#5805) 2026-03-08 21:28:44 +01:00
Uriel
7f2be5f0f5 add more test cases for parse_game_ports 2026-03-08 14:49:06 -03:00
Uriel
2725720406 reformat file and change create_random_port_socket test 2026-03-08 07:01:29 -03:00
Uriel
10d2908339 add tests 2026-03-08 06:32:22 -03:00
Uriel
9653c8d29c simplify tuple conversion check 2026-03-07 22:50:04 -03:00
Uriel
62afec9733 Update WebHostLib/customserver.py
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-07 22:24:13 -03:00
qwint
b53f9d3773 Docs: Better document state.locations_checked (#6018) 2026-03-08 00:51:42 +01:00
Uriel
62f56e165a add return type to weighted random 2026-03-07 18:10:44 -03:00
Uriel
eebd83df76 fix while loop 2026-03-07 17:48:21 -03:00
Uriel
33f03387c4 this should check all usable ports before failing 2026-03-07 17:37:50 -03:00
Uriel
779dd46658 do it the duck way 2026-03-07 17:26:54 -03:00
Uriel
4ea7fbbcba only use ranges 2026-03-07 17:18:03 -03:00
Uriel
9ab7c56791 use a named tuple on parse_game_ports 2026-03-07 17:13:50 -03:00
Uriel
e2823aa044 Apply suggestions from code review
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2026-03-07 16:52:57 -03:00
Uriel
368eafae86 fix random choices and move game_port conversion into tuple 2026-03-07 16:30:20 -03:00
Uriel
6779b4fcf3 fix last_used_ports not being updated locally 2026-03-07 15:09:53 -03:00
Uriel
6a94a9e6ca change game_ports to be tuple 2026-03-07 15:02:08 -03:00
Uriel
c290386950 lazy init get_used_ports 2026-03-07 14:59:33 -03:00
Uriel
60773ddf83 rename variables and functions 2026-03-07 14:43:06 -03:00
Silvris
3ecd856e29 MultiServer: fix Windows compatibility (#6010) 2026-03-06 01:41:48 +01:00
Uriel
980a229aaa fix net_connections not working on macOS 2026-03-05 19:07:44 -03:00
black-sliver
2bec17b397 Merge branch 'main' into feat/data-package-cache 2026-03-05 21:24:50 +00:00
black-sliver
821645a881 MultiServer, customserver: cache: rename module 2026-03-05 22:17:10 +01:00
Uriel
f03d1cad3e use weights for random port and remove more-itertools 2026-03-05 17:01:08 -03:00
Uriel
551dbf44f6 fix some reviews 2026-03-05 16:03:29 -03:00
Uriel
61f893437a Apply suggestions from code review
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2026-03-05 15:51:09 -03:00
Uriel
08a6ee2b3a fix port randomizer 2026-03-05 11:15:33 -03:00
Uriel
b0615590fc add used ports cache and filter used ports when looking for ports 2026-03-05 10:34:33 -03:00
Uriel
0a0faefab2 reuse sockets with websockets api instead of opening and closing them 2026-03-05 09:53:23 -03:00
Uriel
d6473fa0ed fix value type bug on ephemeral type 2026-03-05 08:42:09 -03:00
Uriel
f8b730308d use yaml lists instead of string for config 2026-03-05 08:35:51 -03:00
Uriel
8800124c4e try fixing test with try 2026-03-05 08:08:45 -03:00
Uriel
88dc83e557 remove unused argument 2026-03-05 07:47:32 -03:00
Uriel
ed77f58f13 fix what reviewers said and add some improvements 2026-03-05 07:42:33 -03:00
Uriel
9b098d6f6a Merge branch 'main' into custom-port-range 2026-03-05 05:16:44 -03:00
black-sliver
055acf4826 Test: move customserver tests to not interfere with webhost 2026-03-01 00:14:16 +00:00
black-sliver
2ec4be6f1f customserver: cache: reformat 2026-02-28 21:02:34 +01:00
black-sliver
9996c12ef9 MultiServer, customserver: cache: typing improvements 2026-02-28 20:56:07 +01:00
black-sliver
1346a89a4a customserver: games package cache: fix py3.11 compat 2026-02-28 20:56:07 +01:00
black-sliver
a294e1cdc9 customserver: make WebHost import lazy in games package cache
and fix test
2026-02-28 20:56:07 +01:00
black-sliver
aad980a3a2 Test, MultiServer: reorder imports
Hopefully this fixes the random test failures with pytest-xdist
2026-02-28 19:19:27 +01:00
black-sliver
6f7fce9c73 Test, MultiServer, customser: add tests for games package cache 2026-02-28 15:36:17 +01:00
black-sliver
63bc205dab customserver: typing cleanup in games package cache 2026-02-28 15:35:24 +01:00
black-sliver
4a355f3585 customserver: handle missing checksum in datapackage cache 2026-02-28 15:34:58 +01:00
black-sliver
1cdd657068 MultiServer: improve string deduplication in games package cache 2026-02-28 15:34:10 +01:00
black-sliver
f4ec119900 MultiServer: fix data package cache for missing checksum case 2026-02-27 01:32:56 +01:00
black-sliver
9c00b546dd MultiServer, customserver: minor formatting fixes 2026-02-27 01:24:52 +01:00
black-sliver
59051cda24 MultiServer: fix string deduplication in data package cache 2026-02-27 01:23:28 +01:00
black-sliver
9489a950cb MultiServer, customserver: move data package handling
Create a new class that handles conversion worlds+embedded -> context data.
Create a derived class that uses static_server_data+pony instead.
There is also a not very efficient feature to deduplicate strings (may need perf testing).

By moving code around, we can simplify a lot of the world loading.
Where code lines were touched, some typing and some reformatting was added.
The back compat for GetDataPackage without games was finally dropped.
This was done as a cleanup because the refactoring touched those lines anyway.

Also reworked the per-context dicts and the RoomInfo to hopefully be more efficient
by ignoring unused games. (Generating the list of used games was required for the new
code anyway.)

Side effect of the MultiServer cache: we now load worlds lazily (but still all at once)
and don't modify the games package in place. If needed we create copies.
This almost gets us to the point where MultiServer doesn't need worlds - it still needs
them for the forbidden items.
There is a bonus optimization that deduplicates strings in name_groups that may have bad
performance and may need some perf testing if we run into issues.
2026-02-27 00:53:34 +01:00
Lexipherous
60f6f0f8a8 Merge branch 'main' into main 2025-03-21 09:57:36 +00:00
Lexipherous
7c1726bcc7 Update requirements.txt
Settings requirements to main core branch
2025-03-21 09:57:17 +00:00
Lexipherous
16b47b0a7f Merge branch 'main' into main 2025-03-18 16:13:59 +00:00
lexipherous
41b0c7edc6 Removed dead import from customserver.py 2025-03-18 16:06:48 +00:00
Lexipherous
4d5853d8e3 Merge branch 'main' into HEAD
# Conflicts:
#	WebHostLib/autolauncher.py
#	WebHostLib/customserver.py
2025-02-09 17:45:26 +00:00
Lexipherous
8f4e4cf6b2 Updated soft-fail message 2024-02-04 18:47:08 +00:00
Lexipherous
e5f168e2dd Merge remote-tracking branch 'archipelago-main/main' into HEAD 2024-02-04 18:42:50 +00:00
Lexipherous
e1df5b75ff Merge remote-tracking branch 'archipelago-main/main' into HEAD 2024-01-07 18:02:05 +00:00
Lexipherous
6dd2367696 Merge tag '0.4.4' into HEAD 2024-01-07 17:59:10 +00:00
Lexipherous
9aae61ce0e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	WebHostLib/customserver.py
2023-11-26 23:37:40 +00:00
Fabian Dill
65661e384b Merge branch 'main' into main 2023-09-10 07:24:22 +02:00
Lexipherous
7ba531fb27 Merge remote-tracking branch 'origin/main' 2023-09-09 14:52:11 +01:00
Lexipherous
1815645994 - Added better fallback to default port range when a custom range fails
- Updated config to be clearer
2023-09-09 14:50:37 +01:00
Lexipherous
b326045cb7 Added ability to define custom port ranges the WebHost will use for game servers, instead of pure random. 2023-09-09 14:50:37 +01:00
Lexipherous
392a45ec89 - Added better fallback to default port range when a custom range fails
- Updated config to be clearer
2023-09-09 14:40:52 +01:00
Lexipherous
354c9aea4c Added ability to define custom port ranges the WebHost will use for game servers, instead of pure random. 2023-08-30 22:54:37 +01:00
110 changed files with 5652 additions and 424 deletions

View File

@@ -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]] = []

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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()

View 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
View 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}")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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;

View File

@@ -13,7 +13,3 @@
margin-bottom: 30px;
}
.full-width {
width: 100%;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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
View File

View File

View 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
View File

View File

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

View File

@@ -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;
}
}
}

View File

@@ -134,6 +134,9 @@
# Mega Man 2
/worlds/mm2/ @Silvris
# Mega Man 3
/worlds/mm3/ @Silvris
# MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic

View File

@@ -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

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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: "";

View File

@@ -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():

View File

@@ -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")

View File

View 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],
)

View 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()

View File

View 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"})

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
{
"game": "A Link to the Past",
"minimum_ap_version": "0.6.6",
"world_version": "5.1.0",
"authors": ["Berserker"]
}

View File

@@ -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",

View File

@@ -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))

View File

@@ -0,0 +1,5 @@
{
"game": "Final Fantasy",
"world_version": "1.0.0",
"authors": ["Rosalie"]
}

View File

@@ -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

View File

@@ -0,0 +1,6 @@
{
"game": "Lingo",
"authors": ["hatkirby"],
"minimum_ap_version": "0.6.3",
"world_version": "5.0.0"
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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]})

View 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

View File

@@ -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, "

View 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
View File

@@ -0,0 +1 @@
/src/*

275
worlds/mm3/__init__.py Normal file
View 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]

View 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
View 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
View 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]))

Binary file not shown.

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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))

View File

View 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 :(

View 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)

View File

5
worlds/mm3/test/bases.py Normal file
View File

@@ -0,0 +1,5 @@
from test.bases import WorldTestBase
class MM3TestBase(WorldTestBase):
game = "Mega Man 3"

View 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
View 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)

View File

@@ -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

View File

@@ -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),
}

View File

@@ -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"
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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])

View File

@@ -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(

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
{
"game": "Risk of Rain 2",
"minimum_ap_version": "0.6.4",
"world_version": "1.5.0",
"authors": ["Kindasneaki"]
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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
))

View File

@@ -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

View File

@@ -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():

View File

@@ -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