mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-14 19:43:48 -07:00
Compare commits
1 Commits
core_other
...
webhost_bi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efe48fb432 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,7 +45,6 @@ EnemizerCLI/
|
||||
/SNI/
|
||||
/sni-*/
|
||||
/appimagetool*
|
||||
/VC_redist.x64.exe
|
||||
/host.yaml
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
|
||||
@@ -727,7 +727,6 @@ 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]] = []
|
||||
|
||||
@@ -773,7 +773,7 @@ class CommonContext:
|
||||
if len(parts) == 1:
|
||||
parts = title.split(', ', 1)
|
||||
if len(parts) > 1:
|
||||
text = f"{parts[1]}\n\n{text}" if text else parts[1]
|
||||
text = parts[1] + '\n\n' + text
|
||||
title = parts[0]
|
||||
# display error
|
||||
self._messagebox = MessageBox(title, text, error=True)
|
||||
@@ -896,8 +896,6 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
"May not be running Archipelago on that address or port.")
|
||||
except websockets.InvalidURI:
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||
except asyncio.TimeoutError:
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server. Connection timed out.")
|
||||
except OSError:
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server")
|
||||
except Exception:
|
||||
|
||||
1
Fill.py
1
Fill.py
@@ -280,7 +280,6 @@ 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,
|
||||
|
||||
@@ -21,7 +21,7 @@ import time
|
||||
import typing
|
||||
import weakref
|
||||
import zlib
|
||||
from signal import SIGINT, SIGTERM, signal
|
||||
from signal import SIGINT, SIGTERM
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -2742,23 +2742,12 @@ async def main(args: argparse.Namespace):
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task]))
|
||||
|
||||
def stop():
|
||||
try:
|
||||
for remove_signal in [SIGINT, SIGTERM]:
|
||||
asyncio.get_event_loop().remove_signal_handler(remove_signal)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
for remove_signal in [SIGINT, SIGTERM]:
|
||||
asyncio.get_event_loop().remove_signal_handler(remove_signal)
|
||||
ctx.commandprocessor._cmd_exit()
|
||||
|
||||
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)
|
||||
for signal in [SIGINT, SIGTERM]:
|
||||
asyncio.get_event_loop().add_signal_handler(signal, stop)
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
console_task.cancel()
|
||||
|
||||
@@ -85,7 +85,6 @@ Currently, the following games are supported:
|
||||
* APQuest
|
||||
* Satisfactory
|
||||
* EarthBound
|
||||
* Mega Man 3
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
11
Utils.py
11
Utils.py
@@ -18,8 +18,6 @@ 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
|
||||
@@ -1293,15 +1291,6 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
||||
return isinstance(obj, typing.Iterable)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""
|
||||
Implementation of Python's datetime.utcnow() function for use after deprecation.
|
||||
Needed for timezone-naive UTC datetimes stored in databases with PonyORM (upstream).
|
||||
https://ponyorm.org/ponyorm-list/2014-August/000113.html
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||
"""
|
||||
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
||||
|
||||
@@ -11,7 +11,6 @@ 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')
|
||||
@@ -46,8 +45,6 @@ app.config["SELFGEN"] = True # application process is in charge of scheduling G
|
||||
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.
|
||||
app.config["JOB_TIME"] = 600
|
||||
# maximum time in seconds since last activity for a room to be hosted
|
||||
app.config["MAX_ROOM_TIMEOUT"] = 259200
|
||||
# memory limit for generator processes in bytes
|
||||
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
|
||||
|
||||
@@ -67,7 +64,6 @@ app.config["ASSET_RIGHTS"] = False
|
||||
|
||||
cache = Cache()
|
||||
Compress(app)
|
||||
CLI(app)
|
||||
|
||||
|
||||
def to_python(value: str) -> uuid.UUID:
|
||||
|
||||
@@ -4,14 +4,14 @@ import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import typing
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pony.orm import db_session, select, commit, PrimaryKey, desc
|
||||
from pony.orm import db_session, select, commit, PrimaryKey
|
||||
|
||||
from Utils import restricted_loads, utcnow
|
||||
from Utils import restricted_loads
|
||||
from .locker import Locker, AlreadyRunningException
|
||||
|
||||
_stop_event = Event()
|
||||
@@ -129,11 +129,10 @@ def autohost(config: dict):
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= utcnow() - timedelta(
|
||||
seconds=config["MAX_ROOM_TIMEOUT"])).order_by(desc(Room.last_port))
|
||||
room.last_activity >= datetime.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 >= utcnow() - timedelta(seconds=room.timeout + 5):
|
||||
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
||||
hosters[room.id.int % len(hosters)].start_room(room.id)
|
||||
|
||||
except AlreadyRunningException:
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from flask import Flask
|
||||
|
||||
|
||||
class CLI:
|
||||
def __init__(self, app: Flask) -> None:
|
||||
from .stats import stats_cli
|
||||
|
||||
app.cli.add_command(stats_cli)
|
||||
@@ -1,36 +0,0 @@
|
||||
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}")
|
||||
@@ -172,7 +172,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 = Utils.utcnow()
|
||||
room.last_activity = datetime.datetime.utcnow()
|
||||
return True
|
||||
|
||||
def get_save(self) -> dict:
|
||||
@@ -367,7 +367,8 @@ 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 = Utils.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||
room.last_activity = datetime.datetime.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}.")
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from flask import render_template
|
||||
from pony.orm import count
|
||||
|
||||
from Utils import utcnow
|
||||
from WebHostLib import app, cache
|
||||
from .models import Room, Seed
|
||||
|
||||
@@ -11,6 +10,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 >= utcnow() - timedelta(days=7))
|
||||
seeds = count(seed for seed in Seed if seed.creation_time >= utcnow() - timedelta(days=7))
|
||||
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))
|
||||
return render_template("landing.html", rooms=rooms, seeds=seeds)
|
||||
|
||||
@@ -9,12 +9,11 @@ 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, utcnow
|
||||
from Utils import title_sorted
|
||||
|
||||
class WebWorldTheme(StrEnum):
|
||||
DIRT = "dirt"
|
||||
@@ -234,12 +233,11 @@ def host_room(room: UUID):
|
||||
if room is None:
|
||||
return abort(404)
|
||||
|
||||
now = utcnow()
|
||||
now = datetime.datetime.utcnow()
|
||||
# indicate that the page should reload to get the assigned port
|
||||
should_refresh = (
|
||||
(not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout)
|
||||
)
|
||||
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
||||
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
||||
|
||||
if now - room.last_activity > datetime.timedelta(minutes=1):
|
||||
# we only set last_activity if needed, otherwise parallel access on /room will cause an internal server error
|
||||
# due to "pony.orm.core.OptimisticCheckError: Object Room was updated outside of current transaction"
|
||||
|
||||
@@ -2,8 +2,6 @@ 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
|
||||
@@ -22,8 +20,8 @@ class Slot(db.Entity):
|
||||
|
||||
class Room(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
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
|
||||
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
|
||||
owner = Required(UUID, index=True)
|
||||
commands = Set('Command')
|
||||
seed = Required('Seed', index=True)
|
||||
@@ -40,7 +38,7 @@ class Seed(db.Entity):
|
||||
rooms = Set(Room)
|
||||
multidata = Required(bytes, lazy=True)
|
||||
owner = Required(UUID, index=True)
|
||||
creation_time: datetime = Required(datetime, default=lambda: utcnow(), index=True) # index used by landing page
|
||||
creation_time = Required(datetime, default=lambda: datetime.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
|
||||
|
||||
@@ -13,3 +13,7 @@
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def stats():
|
||||
from worlds import network_data_package
|
||||
known_games = set(network_data_package["games"])
|
||||
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
|
||||
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500)
|
||||
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH * 2, height=1000)
|
||||
|
||||
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, height=500, x_range=(-0.5, 1.2))
|
||||
sizing_mode="scale_both", width=PLOT_WIDTH * 2, height=1000, x_range=(-0.5, 1.2))
|
||||
pie.axis.visible = False
|
||||
pie.xgrid.visible = False
|
||||
pie.ygrid.visible = False
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div id="charts-wrapper">
|
||||
{% for chart in charts %}
|
||||
<div class="chart-container">
|
||||
<div class="chart-container{% if loop.index0 < 2 %} full-width{% endif %}">
|
||||
{{ chart|indent(16)|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -10,7 +10,7 @@ from werkzeug.exceptions import abort
|
||||
|
||||
from MultiServer import Context, get_saving_second
|
||||
from NetUtils import ClientStatus, Hint, NetworkItem, NetworkSlot, SlotType
|
||||
from Utils import restricted_loads, KeyedDefaultDict, utcnow
|
||||
from Utils import restricted_loads, KeyedDefaultDict
|
||||
from . import app, cache
|
||||
from .models import GameDataPackage, Room
|
||||
|
||||
@@ -273,10 +273,9 @@ class TrackerData:
|
||||
Does not include players who have no activity recorded.
|
||||
"""
|
||||
last_activity: Dict[TeamPlayer, datetime.timedelta] = {}
|
||||
now = utcnow()
|
||||
now = datetime.datetime.utcnow()
|
||||
for (team, player), timestamp in self._multisave.get("client_activity_timers", []):
|
||||
from_timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).replace(tzinfo=None)
|
||||
last_activity[team, player] = now - from_timestamp
|
||||
last_activity[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
|
||||
|
||||
return last_activity
|
||||
|
||||
|
||||
@@ -41,8 +41,16 @@ 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;
|
||||
@@ -52,15 +60,5 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# NewSoupVi is acting maintainer, but world belongs to core with the exception of the music
|
||||
/worlds/apquest/ @NewSoupVi
|
||||
|
||||
# Sudoku (APSudoku)
|
||||
/worlds/apsudoku/ @EmilyV99
|
||||
|
||||
# Aquaria
|
||||
/worlds/aquaria/ @tioui
|
||||
@@ -132,9 +134,6 @@
|
||||
# Mega Man 2
|
||||
/worlds/mm2/ @Silvris
|
||||
|
||||
# Mega Man 3
|
||||
/worlds/mm3/ @Silvris
|
||||
|
||||
# MegaMan Battle Network 3
|
||||
/worlds/mmbn3/ @digiholic
|
||||
|
||||
|
||||
@@ -87,8 +87,7 @@ 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/` (see [running from source](/docs/running%20from%20source.md)
|
||||
for setup).
|
||||
repository and creating a new world package in `/worlds/`.
|
||||
|
||||
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
|
||||
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
|
||||
|
||||
@@ -46,8 +46,8 @@ which is the correct way to package your `.apworld` as a world developer. Do not
|
||||
|
||||
### "Build APWorlds" Launcher Component
|
||||
|
||||
In the Archipelago Launcher (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.
|
||||
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.
|
||||
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`.
|
||||
|
||||
@@ -491,10 +491,9 @@ 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 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.
|
||||
# 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.
|
||||
item_name_to_id = {name: id for
|
||||
id, name in enumerate(mygame_items, base_id)}
|
||||
location_name_to_id = {name: id for
|
||||
|
||||
@@ -186,20 +186,9 @@ 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]
|
||||
@@ -237,7 +226,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(self.world.get_locations())
|
||||
copied_state.sweep_for_advancements()
|
||||
# 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:
|
||||
@@ -413,7 +402,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(world.get_locations())
|
||||
er_state.collection_state.sweep_for_advancements()
|
||||
if on_connect:
|
||||
change = on_connect(er_state, placed_exits, paired_entrances)
|
||||
if change:
|
||||
|
||||
@@ -213,11 +213,6 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
|
||||
1
setup.py
1
setup.py
@@ -71,6 +71,7 @@ non_apworlds: set[str] = {
|
||||
"Ocarina of Time",
|
||||
"Overcooked! 2",
|
||||
"Raft",
|
||||
"Sudoku",
|
||||
"Super Mario 64",
|
||||
"VVVVVV",
|
||||
"Wargroove",
|
||||
|
||||
@@ -11,7 +11,7 @@ class TestImplemented(unittest.TestCase):
|
||||
def test_completion_condition(self):
|
||||
"""Ensure a completion condition is set that has requirements."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
if not world_type.hidden and game_name not in {"Sudoku"}:
|
||||
with self.subTest(game_name):
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
self.assertFalse(multiworld.completion_condition[1](multiworld.state))
|
||||
@@ -59,7 +59,7 @@ class TestImplemented(unittest.TestCase):
|
||||
def test_prefill_items(self):
|
||||
"""Test that every world can reach every location from allstate before pre_fill."""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if gamename not in ("Archipelago", "Final Fantasy", "Test Game"):
|
||||
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
|
||||
with self.subTest(gamename):
|
||||
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
|
||||
"set_rules", "connect_entrances", "generate_basic"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from Options import Choice, TextChoice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Options import Choice, ItemLinks, OptionSet, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Utils import restricted_dumps
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
@@ -16,29 +16,6 @@ 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():
|
||||
@@ -109,7 +86,7 @@ class TestOptions(unittest.TestCase):
|
||||
def test_option_set_keys_random(self):
|
||||
"""Tests that option sets do not contain 'random' and its variants as valid keys"""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if game_name not in ("Archipelago", "Super Metroid"):
|
||||
if game_name not in ("Archipelago", "Sudoku", "Super Metroid"):
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
if issubclass(option, OptionSet):
|
||||
with self.subTest(game=game_name, option=option_key):
|
||||
|
||||
@@ -6,7 +6,6 @@ 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:
|
||||
@@ -134,7 +133,7 @@ def stop_room(app_client: "FlaskClient",
|
||||
room_id: str,
|
||||
timeout: Optional[float] = None,
|
||||
simulate_idle: bool = True) -> None:
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
from pony.orm import db_session
|
||||
@@ -152,11 +151,10 @@ def stop_room(app_client: "FlaskClient",
|
||||
|
||||
with db_session:
|
||||
room: Room = Room.get(id=room_uuid)
|
||||
now = utcnow()
|
||||
if simulate_idle:
|
||||
new_last_activity = now - timedelta(seconds=room.timeout + 5)
|
||||
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
|
||||
else:
|
||||
new_last_activity = now - timedelta(days=3)
|
||||
new_last_activity = datetime.utcnow() - timedelta(days=3)
|
||||
room.last_activity = new_last_activity
|
||||
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
|
||||
if address:
|
||||
@@ -190,7 +188,6 @@ 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")
|
||||
|
||||
|
||||
|
||||
@@ -363,7 +363,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
if item == "settings":
|
||||
return getattr(self.__class__, item)
|
||||
return self.__class__.settings
|
||||
raise AttributeError
|
||||
|
||||
# overridable methods that get called by Main.py, sorted by execution order
|
||||
|
||||
@@ -1699,7 +1699,8 @@ def patch_rom(multiworld: MultiWorld, rom: LocalRom, player: int, enemized: bool
|
||||
|
||||
# set rom name
|
||||
# 21 bytes
|
||||
rom.name = bytearray(f'AP{local_world.world_version.as_simple_string().replace(".", "")[0:3]}_{player}_{multiworld.seed:11}\0', 'utf8')[:21]
|
||||
from Utils import __version__
|
||||
rom.name = bytearray(f'AP{__version__.replace(".", "")[0:3]}_{player}_{multiworld.seed:11}\0', 'utf8')[:21]
|
||||
rom.name.extend([0] * (21 - len(rom.name)))
|
||||
rom.write_bytes(0x7FC0, rom.name)
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"game": "A Link to the Past",
|
||||
"minimum_ap_version": "0.6.6",
|
||||
"world_version": "5.1.0",
|
||||
"authors": ["Berserker"]
|
||||
}
|
||||
34
worlds/apsudoku/__init__.py
Normal file
34
worlds/apsudoku/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import Dict
|
||||
|
||||
from BaseClasses import Tutorial
|
||||
from ..AutoWorld import WebWorld, World
|
||||
|
||||
class AP_SudokuWebWorld(WebWorld):
|
||||
options_page = False
|
||||
theme = 'partyTime'
|
||||
|
||||
setup_en = Tutorial(
|
||||
tutorial_name='Setup Guide',
|
||||
description='A guide to playing APSudoku',
|
||||
language='English',
|
||||
file_name='setup_en.md',
|
||||
link='setup/en',
|
||||
authors=['EmilyV']
|
||||
)
|
||||
|
||||
tutorials = [setup_en]
|
||||
|
||||
class AP_SudokuWorld(World):
|
||||
"""
|
||||
Play a little Sudoku while you're in BK mode to maybe get some useful hints
|
||||
"""
|
||||
game = "Sudoku"
|
||||
web = AP_SudokuWebWorld()
|
||||
|
||||
item_name_to_id: Dict[str, int] = {}
|
||||
location_name_to_id: Dict[str, int] = {}
|
||||
|
||||
@classmethod
|
||||
def stage_assert_generate(cls, multiworld):
|
||||
raise Exception("APSudoku cannot be used for generating worlds, the client can instead connect to any slot from any world")
|
||||
|
||||
15
worlds/apsudoku/docs/en_Sudoku.md
Normal file
15
worlds/apsudoku/docs/en_Sudoku.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# APSudoku
|
||||
|
||||
## Hint Games
|
||||
|
||||
HintGames do not need to be added at the start of a seed, and do not create a 'slot'- instead, you connect the HintGame client to a different game's slot. By playing a HintGame, you can earn hints for the connected slot.
|
||||
|
||||
## What is this game?
|
||||
|
||||
Play Sudoku puzzles of varying difficulties, earning a hint for each puzzle correctly solved. Harder puzzles are more likely to grant a hint towards a Progression item, though otherwise what hint is granted is random.
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
There is no options page; this game cannot be used in your .yamls. Instead, the client can connect to any slot in a multiworld.
|
||||
|
||||
By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room. This allows disabling hints entirely, as well as altering the hint odds for each difficulty.
|
||||
55
worlds/apsudoku/docs/setup_en.md
Normal file
55
worlds/apsudoku/docs/setup_en.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# APSudoku Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [APSudoku](https://github.com/APSudoku/APSudoku)
|
||||
|
||||
## General Concept
|
||||
|
||||
This is a HintGame client, which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations.
|
||||
|
||||
Does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files.
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### Windows / Linux
|
||||
Go to the latest release from the [github APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform.
|
||||
|
||||
### Web
|
||||
Go to the [github pages](apsudoku.github.io) or [itch.io](https://emilyv99.itch.io/apsudoku) site, and play in the browser.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Run the APSudoku executable.
|
||||
2. Under `Settings` → `Connection` at the top-right:
|
||||
- Enter the server address and port number
|
||||
- Enter the name of the slot you wish to connect to
|
||||
- Enter the room password (optional)
|
||||
- Select DeathLink related settings (optional)
|
||||
- Press `Connect`
|
||||
4. Under the `Sudoku` tab
|
||||
- Choose puzzle difficulty
|
||||
- Click `Start` to generate a puzzle
|
||||
5. Try to solve the Sudoku. Click `Check` when done
|
||||
- A correct solution rewards you with 1 hint for a location in the world you are connected to
|
||||
- An incorrect solution has no penalty, unless DeathLink is enabled (see below)
|
||||
|
||||
Info:
|
||||
- You can set various settings under `Settings` → `Sudoku`, and can change the colors used under `Settings` → `Theme`.
|
||||
- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features
|
||||
- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md)
|
||||
- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every locations is already hinted)
|
||||
- Click the various `?` buttons for information on controls/how to play
|
||||
|
||||
## Admin Settings
|
||||
|
||||
By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room.
|
||||
|
||||
- You can disable APSudoku for the entire room, preventing any hints from being granted.
|
||||
- You can customize the reward weights for each difficulty, making progression hints more or less likely, and/or adding a chance to get "no hint" after a solve.
|
||||
|
||||
## DeathLink Support
|
||||
|
||||
If `DeathLink` is enabled when you click `Connect`:
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle- if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting).
|
||||
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
- On receiving a DeathLink from another player, your puzzle resets.
|
||||
@@ -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, balcony before drop", "Red Tearstone Ring"),
|
||||
DS3LocationData("LC: Red Tearstone Ring - chapel, drop onto roof", "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 - ascent, chest at beginning", "Hood of Prayer"),
|
||||
DS3LocationData("LC: Hood of Prayer", "Hood of Prayer"),
|
||||
DS3LocationData("LC: Robe of Prayer - ascent, chest at beginning", "Robe of Prayer"),
|
||||
DS3LocationData("LC: Skirt of Prayer - ascent, chest at beginning", "Skirt of Prayer"),
|
||||
DS3LocationData("LC: Spirit Tree Crest Shield - basement, chest",
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -1474,7 +1473,6 @@ 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]
|
||||
|
||||
@@ -1482,12 +1480,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
|
||||
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)
|
||||
# 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
|
||||
|
||||
if ds3_world.options.smooth_upgrade_items:
|
||||
base_names = {
|
||||
@@ -1520,6 +1518,19 @@ 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))
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"game": "Final Fantasy",
|
||||
"world_version": "1.0.0",
|
||||
"authors": ["Rosalie"]
|
||||
}
|
||||
@@ -26,10 +26,7 @@ class GenericWeb(WebWorld):
|
||||
'English', 'setup_en.md', 'setup/en', ['alwaysintreble'])
|
||||
triggers = Tutorial('Archipelago Triggers Guide', 'A guide to setting up and using triggers in your game settings.',
|
||||
'English', 'triggers_en.md', 'triggers/en', ['alwaysintreble'])
|
||||
other_games = Tutorial('Other Games and Tools',
|
||||
'A guide to additional games and tools that can be used with Archipelago.',
|
||||
'English', 'other_en.md', 'other/en', ['Berserker'])
|
||||
tutorials = [setup, mac, commands, advanced_settings, triggers, plando, other_games]
|
||||
tutorials = [setup, mac, commands, advanced_settings, triggers, plando]
|
||||
|
||||
|
||||
class GenericWorld(World):
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# Other Games and Tools
|
||||
|
||||
This guide provides information on additional community resources, tools, and games that function with Archipelago.
|
||||
|
||||
## Community Resources
|
||||
|
||||
The Archipelago community is active across several platforms where you can find support, new games, and tools.
|
||||
|
||||
### Discord Servers
|
||||
Archipelago has two primary Discord servers for community interaction, game support, and hosting public games:
|
||||
- **[Archipelago Official Discord](https://discord.gg/8Z65BR2)**: The main hub for the community, including general discussion, support, and public multiworld hosting.
|
||||
- **[Archipelago After Dark Discord](https://discord.gg/fqvNCCRsu4)**: An adults-only server for 18+ and unrated content.
|
||||
|
||||
Both servers feature an **#apworld-index** channel. These channels are repositories for "APWorlds" — additional game implementations that can be easily added to your Archipelago installation to support more games.
|
||||
|
||||
### Documentation
|
||||
- **[Archipelago Wiki](https://archipelago.miraheze.org/)**: A community-maintained wiki.
|
||||
|
||||
## Community Tools
|
||||
|
||||
These community-developed tools are frequently used alongside Archipelago to improve the player experience.
|
||||
|
||||
### PopTracker
|
||||
**[PopTracker](https://github.com/black-sliver/PopTracker)** is a universal multi-platform tracking application designed for randomizers. It supports many Archipelago games through tracker packs, providing both manual and automatic tracking capabilities by connecting directly to an Archipelago server or a console/emulator.
|
||||
|
||||
## APSudoku
|
||||
|
||||
### What is this game?
|
||||
APSudoku is a HintGame client which can connect to any multiworld slot, allowing you to play Sudoku to unlock random hints for that slot's locations.
|
||||
It does not need to be added at the start of a seed, as it does not create any slots of its own, nor does it have any YAML files.
|
||||
|
||||
### Required Software
|
||||
- [APSudoku](https://github.com/APSudoku/APSudoku)
|
||||
|
||||
### Installation Procedures
|
||||
#### Windows / Linux
|
||||
Go to the latest release from the [GitHub APSudoku Releases page](https://github.com/APSudoku/APSudoku/releases/latest). Download and extract the appropriate file for your platform.
|
||||
#### Web
|
||||
Go to the [GitHub pages](https://apsudoku.github.io) or [itch.io](https://emilyv99.itch.io/apsudoku) site, and play in the browser.
|
||||
|
||||
### Joining a MultiWorld Game
|
||||
1. Run the APSudoku executable.
|
||||
2. Under `Settings` → `Connection` at the top-right:
|
||||
- Enter the server address and port number
|
||||
- Enter the name of the slot you wish to connect to
|
||||
- Enter the room password (optional)
|
||||
- Select DeathLink related settings (optional)
|
||||
- Press `Connect`
|
||||
3. Under the `Sudoku` tab:
|
||||
- Choose puzzle difficulty
|
||||
- Click `Start` to generate a puzzle
|
||||
4. Try to solve the Sudoku. Click `Check` when done.
|
||||
- A correct solution rewards you with 1 hint for a location in the world you are connected to.
|
||||
- An incorrect solution has no penalty, unless DeathLink is enabled (see below).
|
||||
|
||||
### Additional Information
|
||||
- You can set various settings under `Settings` → `Sudoku`, and can change the colors used under `Settings` → `Theme`.
|
||||
- While connected, you can view the `Console` and `Hints` tabs for standard TextClient-like features.
|
||||
- You can also use the `Tracking` tab to view either a basic tracker or a valid [GodotAP tracker pack](https://github.com/EmilyV99/GodotAP/blob/main/tracker_packs/GET_PACKS.md).
|
||||
- While connected, the number of "unhinted" locations for your slot is shown in the upper-left of the `Sudoku` tab. (If this reads 0, no further hints can be earned for this slot, as every location is already hinted.)
|
||||
- Click the various `?` buttons for information on controls/how to play.
|
||||
|
||||
### Admin Settings
|
||||
By using the connected room's Admin Password on the Admin Panel tab, you can configure some settings at any time to affect the entire room.
|
||||
- You can disable APSudoku for the entire room, preventing any hints from being granted.
|
||||
- You can customize the reward weights for each difficulty, making progression hints more or less likely, and/or adding a chance to get "no hint" after a solve.
|
||||
|
||||
### DeathLink Support
|
||||
If `DeathLink` is enabled when you click `Connect`:
|
||||
- Lose a life if you check an incorrect puzzle (not an _incomplete_ puzzle — if any cells are empty, you get off with a warning), or if you quit a puzzle without solving it (including disconnecting).
|
||||
- Your life count is customizable (default 0). Dying with 0 lives left kills linked players AND resets your puzzle.
|
||||
- On receiving a DeathLink from another player, your puzzle resets.
|
||||
@@ -216,28 +216,6 @@ 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
|
||||
|
||||
@@ -1281,7 +1281,7 @@ exclusion_table = {
|
||||
LocationName.HadesCupTrophyParadoxCups,
|
||||
LocationName.MusicalOrichalcumPlus,
|
||||
],
|
||||
"HitlistCasual": [
|
||||
"HitlistCasual": {
|
||||
LocationName.FuturePete,
|
||||
LocationName.BetwixtandBetweenBondofFlame,
|
||||
LocationName.GrimReaper2,
|
||||
@@ -1299,7 +1299,7 @@ exclusion_table = {
|
||||
LocationName.MCP,
|
||||
LocationName.Lvl50,
|
||||
LocationName.Lvl99
|
||||
],
|
||||
},
|
||||
"Cups": {
|
||||
LocationName.ProtectBeltPainandPanicCup,
|
||||
LocationName.SerenityGemPainandPanicCup,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"game": "Lingo",
|
||||
"authors": ["hatkirby"],
|
||||
"minimum_ap_version": "0.6.3",
|
||||
"world_version": "5.0.0"
|
||||
}
|
||||
@@ -4470,10 +4470,6 @@
|
||||
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
|
||||
@@ -4616,7 +4612,7 @@
|
||||
enter_only: True
|
||||
orientation: east
|
||||
required_door:
|
||||
door: First Eight
|
||||
door: Eights
|
||||
progression:
|
||||
Progressive Number Hunt:
|
||||
panel_doors:
|
||||
|
||||
Binary file not shown.
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
from typing import Any, ClassVar, TextIO
|
||||
|
||||
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, MultiWorld, Tutorial, \
|
||||
PlandoOptions
|
||||
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, MultiWorld, Tutorial
|
||||
from Options import Accessibility
|
||||
from Utils import output_path
|
||||
from settings import FilePath, Group
|
||||
@@ -19,7 +18,6 @@ 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(
|
||||
@@ -153,10 +151,6 @@ 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
|
||||
@@ -194,11 +188,6 @@ 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
|
||||
@@ -290,16 +279,6 @@ 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
|
||||
@@ -316,7 +295,7 @@ class MessengerWorld(World):
|
||||
raise RuntimeError("Unable to generate valid portal output.")
|
||||
|
||||
if self.options.shuffle_transitions:
|
||||
shuffle_transitions(self, keep_entrance_logic)
|
||||
shuffle_transitions(self)
|
||||
|
||||
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
|
||||
if self.options.available_portals < 6:
|
||||
@@ -484,7 +463,7 @@ class MessengerWorld(World):
|
||||
"loc_data": {loc.address: {loc.item.name: [loc.item.code, loc.item.flags]}
|
||||
for loc in multiworld.get_filled_locations() if loc.address},
|
||||
}
|
||||
|
||||
|
||||
output = orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(output)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, Region
|
||||
from BaseClasses import CollectionState, Entrance, EntranceType, Item, ItemClassification, Location, Region
|
||||
from entrance_rando import ERPlacementState
|
||||
from .regions import LOCATIONS, MEGA_SHARDS
|
||||
from .shop import FIGURINES, SHOP_ITEMS
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from BaseClasses import Region, CollectionRule
|
||||
from BaseClasses import Entrance, Region
|
||||
from entrance_rando import EntranceType, randomize_entrances
|
||||
from .connections import RANDOMIZED_CONNECTIONS, TRANSITIONS
|
||||
from .options import ShuffleTransitions, TransitionPlando
|
||||
@@ -26,6 +26,7 @@ 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")
|
||||
@@ -35,9 +36,8 @@ def disconnect_entrances(world: "MessengerWorld") -> None:
|
||||
entrance = world.get_entrance(f"{parent} -> {child}")
|
||||
disconnect_entrance()
|
||||
|
||||
|
||||
def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando, keep_logic: bool = False) -> None:
|
||||
def remove_dangling_exit(region: Region) -> CollectionRule:
|
||||
def connect_plando(world: "MessengerWorld", plando_connections: TransitionPlando) -> None:
|
||||
def remove_dangling_exit(region: Region) -> None:
|
||||
# find the disconnected exit and remove references to it
|
||||
for _exit in region.exits:
|
||||
if not _exit.connected_region:
|
||||
@@ -45,7 +45,6 @@ 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
|
||||
@@ -66,35 +65,30 @@ 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)
|
||||
access_rule = remove_dangling_exit(reg1)
|
||||
|
||||
remove_dangling_exit(reg1)
|
||||
|
||||
reg2 = world.get_region(plando_connection.exit)
|
||||
remove_dangling_entrance(reg2)
|
||||
# connect the regions
|
||||
new_exit1 = reg1.connect(reg2)
|
||||
if keep_logic:
|
||||
new_exit1.access_rule = access_rule
|
||||
reg1.connect(reg2)
|
||||
|
||||
# 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):
|
||||
access_rule = remove_dangling_exit(reg2)
|
||||
remove_dangling_exit(reg2)
|
||||
remove_dangling_entrance(reg1)
|
||||
new_exit2 = reg2.connect(reg1)
|
||||
if keep_logic:
|
||||
new_exit2.access_rule = access_rule
|
||||
reg2.connect(reg1)
|
||||
|
||||
|
||||
def shuffle_transitions(world: "MessengerWorld", keep_logic: bool = False) -> None:
|
||||
def shuffle_transitions(world: "MessengerWorld") -> None:
|
||||
coupled = world.options.shuffle_transitions == ShuffleTransitions.option_coupled
|
||||
|
||||
plando = world.options.plando_connections
|
||||
if plando:
|
||||
connect_plando(world, plando, keep_logic)
|
||||
connect_plando(world, plando)
|
||||
|
||||
result = randomize_entrances(world, coupled, {0: [0]})
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
from Options import PlandoConnection
|
||||
from .connections import RANDOMIZED_CONNECTIONS
|
||||
from .portals import REGION_ORDER, SHOP_POINTS, CHECKPOINTS
|
||||
from .transitions import TRANSITIONS
|
||||
|
||||
REVERSED_RANDOMIZED_CONNECTIONS = {v: k for k, v in RANDOMIZED_CONNECTIONS.items()}
|
||||
|
||||
|
||||
def find_spot(portal_key: int) -> str:
|
||||
"""finds the spot associated with the portal key"""
|
||||
parent = REGION_ORDER[portal_key // 100]
|
||||
if portal_key % 100 == 0:
|
||||
return f"{parent} Portal"
|
||||
if portal_key % 100 // 10 == 1:
|
||||
return SHOP_POINTS[parent][portal_key % 10]
|
||||
return CHECKPOINTS[parent][portal_key % 10]
|
||||
|
||||
|
||||
def reverse_portal_exits_into_portal_plando(portal_exits: list[int]) -> list[PlandoConnection]:
|
||||
return [
|
||||
PlandoConnection("Autumn Hills", find_spot(portal_exits[0]), "both"),
|
||||
PlandoConnection("Riviere Turquoise", find_spot(portal_exits[1]), "both"),
|
||||
PlandoConnection("Howling Grotto", find_spot(portal_exits[2]), "both"),
|
||||
PlandoConnection("Sunken Shrine", find_spot(portal_exits[3]), "both"),
|
||||
PlandoConnection("Searing Crags", find_spot(portal_exits[4]), "both"),
|
||||
PlandoConnection("Glacial Peak", find_spot(portal_exits[5]), "both"),
|
||||
]
|
||||
|
||||
|
||||
def reverse_transitions_into_plando_connections(transitions: list[list[int]]) -> list[PlandoConnection]:
|
||||
plando_connections = []
|
||||
|
||||
for connection in [
|
||||
PlandoConnection(REVERSED_RANDOMIZED_CONNECTIONS[TRANSITIONS[transition[0]]], TRANSITIONS[transition[1]], "both")
|
||||
for transition in transitions
|
||||
]:
|
||||
if connection.exit in {con.entrance for con in plando_connections}:
|
||||
continue
|
||||
plando_connections.append(connection)
|
||||
|
||||
return plando_connections
|
||||
@@ -1,11 +1,11 @@
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Optional, Set, List, Dict
|
||||
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,6 +41,8 @@ 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")])
|
||||
@@ -70,15 +72,20 @@ 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.server_seed_name is None:
|
||||
if ctx.seed_name is None:
|
||||
return
|
||||
if not self.seed_verify:
|
||||
seed = await bizhawk.read(ctx.bizhawk_ctx, [(0xDF00A0, len(ctx.server_seed_name), "ROM")])
|
||||
seed = await bizhawk.read(ctx.bizhawk_ctx, [(0xDF00A0, len(ctx.seed_name), "ROM")])
|
||||
seed = seed[0].decode("UTF-8")
|
||||
if seed not in ctx.server_seed_name:
|
||||
if seed not in ctx.seed_name:
|
||||
logger.info(
|
||||
"ERROR: The ROM you loaded is for a different game of AP. "
|
||||
"Please make sure the host has sent you the correct patch file, "
|
||||
|
||||
@@ -140,8 +140,8 @@ def cmd_pool(self: "BizHawkClientCommandProcessor") -> None:
|
||||
|
||||
|
||||
def cmd_request(self: "BizHawkClientCommandProcessor", amount: str, target: str) -> None:
|
||||
"""Request a refill from EnergyLink."""
|
||||
from worlds._bizhawk.context import BizHawkClientContext
|
||||
"""Request a refill from EnergyLink."""
|
||||
if self.ctx.game != "Mega Man 2":
|
||||
logger.warning("This command can only be used when playing Mega Man 2.")
|
||||
return
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/src/*
|
||||
@@ -1,275 +0,0 @@
|
||||
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]
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"game": "Mega Man 3",
|
||||
"authors": ["Silvris"],
|
||||
"world_version": "0.1.7",
|
||||
"minimum_ap_version": "0.6.4"
|
||||
}
|
||||
@@ -1,783 +0,0 @@
|
||||
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]}])
|
||||
@@ -1,331 +0,0 @@
|
||||
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.
@@ -1,131 +0,0 @@
|
||||
# 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
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,80 +0,0 @@
|
||||
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}
|
||||
@@ -1,312 +0,0 @@
|
||||
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}
|
||||
@@ -1,221 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,164 +0,0 @@
|
||||
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
|
||||
@@ -1,374 +0,0 @@
|
||||
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)
|
||||
@@ -1,388 +0,0 @@
|
||||
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))
|
||||
@@ -1,781 +0,0 @@
|
||||
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 :(
|
||||
@@ -1,8 +0,0 @@
|
||||
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)
|
||||
@@ -1,5 +0,0 @@
|
||||
from test.bases import WorldTestBase
|
||||
|
||||
|
||||
class MM3TestBase(WorldTestBase):
|
||||
game = "Mega Man 3"
|
||||
@@ -1,105 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
from collections import defaultdict
|
||||
from typing import DefaultDict
|
||||
|
||||
MM3_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda: 0x25, {
|
||||
'0': 0x00,
|
||||
'1': 0x01,
|
||||
'2': 0x02,
|
||||
'3': 0x03,
|
||||
'4': 0x04,
|
||||
'5': 0x05,
|
||||
'6': 0x06,
|
||||
'7': 0x07,
|
||||
'8': 0x08,
|
||||
'9': 0x09,
|
||||
'A': 0x0A,
|
||||
'B': 0x0B,
|
||||
'C': 0x0C,
|
||||
'D': 0x0D,
|
||||
'E': 0x0E,
|
||||
'F': 0x0F,
|
||||
'G': 0x10,
|
||||
'H': 0x11,
|
||||
'I': 0x12,
|
||||
'J': 0x13,
|
||||
'K': 0x14,
|
||||
'L': 0x15,
|
||||
'M': 0x16,
|
||||
'N': 0x17,
|
||||
'O': 0x18,
|
||||
'P': 0x19,
|
||||
'Q': 0x1A,
|
||||
'R': 0x1B,
|
||||
'S': 0x1C,
|
||||
'T': 0x1D,
|
||||
'U': 0x1E,
|
||||
'V': 0x1F,
|
||||
'W': 0x20,
|
||||
'X': 0x21,
|
||||
'Y': 0x22,
|
||||
'Z': 0x23,
|
||||
' ': 0x25,
|
||||
'.': 0x26,
|
||||
',': 0x27,
|
||||
'\'': 0x28,
|
||||
'!': 0x29,
|
||||
':': 0x2B
|
||||
})
|
||||
|
||||
|
||||
class MM3TextEntry:
|
||||
def __init__(self, text: str = "", y_coords: int = 0xA5, row: int = 0x21):
|
||||
self.target_area: int = row # don't change
|
||||
self.coords: int = y_coords # 0xYX, Y can only be increments of 0x20
|
||||
self.text: str = text
|
||||
|
||||
def resolve(self) -> bytes:
|
||||
data = bytearray()
|
||||
data.append(self.target_area)
|
||||
data.append(self.coords)
|
||||
data.append(12)
|
||||
data.extend([MM3_WEAPON_ENCODING[x] for x in self.text.upper()])
|
||||
data.extend([0x25] * (13 - len(self.text)))
|
||||
return bytes(data)
|
||||
@@ -28,7 +28,6 @@ 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 = [
|
||||
@@ -39,7 +38,6 @@ class MuseDashCollections:
|
||||
"Tsukuyomi Ni Naru Replaced",
|
||||
"Heart Message feat. Aoi Tokimori Secret",
|
||||
"Meow Rock feat. Chun Ge, Yuan Shen",
|
||||
"Stra Stella Secret",
|
||||
]
|
||||
|
||||
song_items = SONG_DATA
|
||||
|
||||
@@ -625,7 +625,7 @@ SONG_DATA: Dict[str, SongData] = {
|
||||
"Synthesis.": SongData(2900749, "83-1", "Cosmic Radio 2024", True, 6, 8, 10),
|
||||
"COSMiC FANFARE!!!!": SongData(2900750, "83-2", "Cosmic Radio 2024", False, 7, 9, 11),
|
||||
"Sharp Bubbles": SongData(2900751, "83-3", "Cosmic Radio 2024", True, 7, 9, 11),
|
||||
"Replay": SongData(2900752, "83-4", "Cosmic Radio 2024", False, 5, 7, 9),
|
||||
"Replay": SongData(2900752, "83-4", "Cosmic Radio 2024", True, 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,30 +677,4 @@ SONG_DATA: Dict[str, SongData] = {
|
||||
"City Lights": SongData(2900801, "90-3", "MEDIUM5 Echoes", True, 4, 6, 9),
|
||||
"Polaris Wandering Night": SongData(2900802, "90-4", "MEDIUM5 Echoes", True, 5, 8, 10),
|
||||
"Chasing the Moonlight": SongData(2900803, "90-5", "MEDIUM5 Echoes", True, 4, 6, 8),
|
||||
"WILDCARD": SongData(2900804, "91-0", "48 Hours After Discharge", True, 3, 6, 9),
|
||||
"It was all just a dream!": SongData(2900805, "91-1", "48 Hours After Discharge", True, 5, 7, 9),
|
||||
"Science": SongData(2900806, "91-2", "48 Hours After Discharge", False, 4, 7, 9),
|
||||
"Hit Maker": SongData(2900807, "91-3", "48 Hours After Discharge", False, 4, 6, 9),
|
||||
"THX 4 playing": SongData(2900808, "91-4", "48 Hours After Discharge", True, 3, 5, 8),
|
||||
"Theory of Existence": SongData(2900809, "91-5", "48 Hours After Discharge", True, 4, 6, 9),
|
||||
"Kirakira Noel Story!!": SongData(2900810, "43-68", "MD Plus Project", False, 6, 8, 10),
|
||||
"Fantasista LAST END": SongData(2900811, "92-0", "HARDCORE MOTTO TANO*C", True, 7, 9, 11),
|
||||
"Colorful Universe": SongData(2900812, "92-1", "HARDCORE MOTTO TANO*C", True, 3, 6, 9),
|
||||
"Future Flux": SongData(2900813, "92-2", "HARDCORE MOTTO TANO*C", True, 5, 8, 10),
|
||||
"SOMEONE STOP ME!!!": SongData(2900814, "92-3", "HARDCORE MOTTO TANO*C", True, 6, 8, 10),
|
||||
"Azathoth": SongData(2900815, "92-4", "HARDCORE MOTTO TANO*C", True, 6, 8, 10),
|
||||
"Change the Game feat. Iori Matsunaga": SongData(2900816, "92-5", "HARDCORE MOTTO TANO*C", False, 6, 8, 10),
|
||||
"Stra Stella Secret": SongData(2900817, "0-59", "Default Music", False, 6, 8, 10),
|
||||
"Stra Stella": SongData(2900818, "0-60", "Default Music", False, 1, 4, None),
|
||||
"Ultra-Digital Super Detox": SongData(2900819, "43-69", "MD Plus Project", False, 3, 6, 9),
|
||||
"Otsukimi Koete Otsukiai": SongData(2900820, "43-70", "MD Plus Project", True, 6, 8, 10),
|
||||
"Obenkyou Time": SongData(2900821, "43-71", "MD Plus Project", False, 6, 8, 11),
|
||||
"Retry Now": SongData(2900822, "43-72", "MD Plus Project", False, 3, 6, 9),
|
||||
"Master Bancho's Sushi Class ": SongData(2900823, "93-0", "Welcome to the Blue Hole!", False, None, None, None),
|
||||
"CHAOTiC BATTLE": SongData(2900824, "94-0", "Cosmic Radio 2025", False, 7, 9, 11),
|
||||
"FATAL GAME": SongData(2900825, "94-1", "Cosmic Radio 2025", False, 3, 6, 9),
|
||||
"Aria": SongData(2900826, "94-2", "Cosmic Radio 2025", False, 4, 6, 9),
|
||||
"+1 UNKNOWN -NUMBER": SongData(2900827, "94-3", "Cosmic Radio 2025", True, 4, 7, 10),
|
||||
"To the Beyond, from the Nameless Seaside": SongData(2900828, "94-4", "Cosmic Radio 2025", False, 5, 8, 10),
|
||||
"REK421": SongData(2900829, "94-5", "Cosmic Radio 2025", True, 7, 9, 11),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"game": "Muse Dash",
|
||||
"authors": ["DeamonHunter"],
|
||||
"world_version": "1.5.29",
|
||||
"world_version": "1.5.26",
|
||||
"minimum_ap_version": "0.6.3"
|
||||
}
|
||||
@@ -272,7 +272,7 @@ def patch_rom(world, rom):
|
||||
world_str = ""
|
||||
rom.write_bytes(rom.sym('WORLD_STRING_TXT'), makebytes(world_str, 12))
|
||||
|
||||
time_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M") + " UTC"
|
||||
time_str = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M") + " UTC"
|
||||
rom.write_bytes(rom.sym('TIME_STRING_TXT'), makebytes(time_str, 25))
|
||||
|
||||
rom.write_byte(rom.sym('CFG_SHOW_SETTING_INFO'), 0x01)
|
||||
|
||||
@@ -7,7 +7,7 @@ Da wir BizHawk benutzen, gilt diese Anleitung nur für Windows und Linux.
|
||||
## Benötigte Software
|
||||
|
||||
- BizHawk: [BizHawk Veröffentlichungen von TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Version 2.10 und neuer werden unterstützt. Version 2.10 ist empfohlen.
|
||||
- Version 2.3.1 und später 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,6 +19,11 @@ Da wir BizHawk benutzen, gilt diese Anleitung nur für Windows und Linux.
|
||||
|
||||
Sobald Bizhawk einmal installiert wurde, öffne **EmuHawk** und ändere die folgenen Einsteluungen:
|
||||
|
||||
- (≤ 2.8) Gehe zu `Config > Customize`. Wechlse zu dem `Advanced`-Reiter, wechsle dann den `Lua Core` von "NLua+KopiLua" zu
|
||||
`"Lua+LuaInterface"`. Starte danach EmuHawk neu. Dies ist zwingend notwendig, damit die Lua-Scripts, mit denen man sich mit dem Client verbindet, ordnungsgemäß funktionieren.
|
||||
**ANMERKUNG: Selbst wenn "Lua+LuaInterface" bereits ausgewählt ist, wechsle zwischen den beiden Optionen umher und**
|
||||
**wähle es erneut aus. Neue Installationen oder Versionen von EmuHawk neigen dazu "Lua+LuaInterface" als die**
|
||||
**Standard-Option anzuzeigen, aber laden dennoch "NLua+KopiLua", bis dieser Schritt getan ist.**
|
||||
- Unter `Config > Customize > Advanced`, gehe sicher dass der Haken bei `AutoSaveRAM` ausgeählt ist, und klicke dann
|
||||
den 5s-Knopf. Dies verringert die Wahrscheinlichkeit den Speicherfrotschritt zu verlieren, sollte der Emulator mal
|
||||
abstürzen.
|
||||
|
||||
@@ -7,7 +7,7 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst
|
||||
## Required Software
|
||||
|
||||
- BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Version 2.10 and later are supported. Version 2.10 is recommended for stability.
|
||||
- Version 2.3.1 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,6 +17,11 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst
|
||||
|
||||
Once BizHawk has been installed, open EmuHawk and change the following settings:
|
||||
|
||||
- (≤ 2.8) Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to
|
||||
"Lua+LuaInterface". Then restart EmuHawk. This is required for the Lua script to function correctly.
|
||||
**NOTE: Even if "Lua+LuaInterface" is already selected, toggle between the two options and reselect it. Fresh installs**
|
||||
**of newer versions of EmuHawk have a tendency to show "Lua+LuaInterface" as the default selected option but still load**
|
||||
**"NLua+KopiLua" until this step is done.**
|
||||
- Under Config > Customize > Advanced, make sure the box for AutoSaveRAM is checked, and click the 5s button.
|
||||
This reduces the possibility of losing save data in emulator crashes.
|
||||
- Under Config > Customize, check the "Run in background" and "Accept background input" boxes. This will allow you to
|
||||
|
||||
@@ -7,7 +7,7 @@ Comme nous utilisons BizHawk, ce guide s'applique uniquement aux systèmes Windo
|
||||
## Logiciel requis
|
||||
|
||||
- BizHawk : [Sorties BizHawk de TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Les versions 2.10 et ultérieures sont prises en charge. La version 2.10 est recommandée pour des raisons de stabilité.
|
||||
- Les versions 2.3.1 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,6 +18,10 @@ Comme nous utilisons BizHawk, ce guide s'applique uniquement aux systèmes Windo
|
||||
|
||||
Une fois BizHawk installé, ouvrez EmuHawk et modifiez les paramètres suivants :
|
||||
|
||||
- (≤ 2,8) Allez dans Config > Personnaliser. Passez à l'onglet Avancé, puis faites passer le Lua Core de "NLua+KopiLua" à
|
||||
"Lua+LuaInterface". Puis redémarrez EmuHawk. Ceci est nécessaire pour que le script Lua fonctionne correctement.
|
||||
**REMARQUE : Même si « Lua+LuaInterface » est déjà sélectionné, basculez entre les deux options et resélectionnez-la. Nouvelles installations**
|
||||
**des versions plus récentes d'EmuHawk ont tendance à afficher "Lua+LuaInterface" comme option sélectionnée par défaut mais ce pendant refait l'épate juste au dessus par précautions**
|
||||
- Sous Config > Personnaliser > Avancé, assurez-vous que la case AutoSaveRAM est cochée et cliquez sur le bouton 5s.
|
||||
Cela réduit la possibilité de perdre des données de sauvegarde en cas de crash de l'émulateur.
|
||||
- Sous Config > Personnaliser, cochez les cases « Exécuter en arrière-plan » et « Accepter la saisie en arrière-plan ». Cela vous permettra continuez à jouer en arrière-plan, même si une autre fenêtre est sélectionnée.
|
||||
|
||||
@@ -123,7 +123,6 @@ 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
|
||||
|
||||
@@ -143,7 +142,6 @@ 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 = []
|
||||
@@ -267,7 +265,6 @@ 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,
|
||||
@@ -497,6 +494,7 @@ class PokemonEmeraldWorld(World):
|
||||
set_rules(self)
|
||||
|
||||
def connect_entrances(self):
|
||||
randomize_wild_encounters(self)
|
||||
self.shuffle_badges_hms()
|
||||
# For entrance randomization, disconnect entrances here, randomize map, then
|
||||
# undo badge/HM placement and re-shuffle them in the new map.
|
||||
|
||||
@@ -110,7 +110,7 @@ def create_locations_by_category(world: "PokemonEmeraldWorld", regions: Dict[str
|
||||
national_dex_id = int(location_name[-3:]) # Location names are formatted POKEDEX_REWARD_###
|
||||
|
||||
# Don't create this pokedex location if player can't find it in the wild
|
||||
if NATIONAL_ID_TO_SPECIES_ID[national_dex_id] in world.blacklisted_wilds or NATIONAL_ID_TO_SPECIES_ID[national_dex_id] not in world.allowed_dexsanity_species:
|
||||
if NATIONAL_ID_TO_SPECIES_ID[national_dex_id] in world.blacklisted_wilds:
|
||||
continue
|
||||
|
||||
location_id += POKEDEX_OFFSET + national_dex_id
|
||||
|
||||
@@ -63,7 +63,7 @@ def randomize_opponent_parties(world: "PokemonEmeraldWorld") -> None:
|
||||
if len(merged_blacklist) < NUM_REAL_SPECIES:
|
||||
break
|
||||
else:
|
||||
merged_blacklist: Set[int] = set()
|
||||
raise RuntimeError("This should never happen")
|
||||
|
||||
candidates = [
|
||||
species
|
||||
|
||||
@@ -4,7 +4,7 @@ Option definitions for Pokemon Emerald
|
||||
from dataclasses import dataclass
|
||||
|
||||
from Options import (Choice, DeathLink, DefaultOnToggle, OptionSet, NamedRange, Range, Toggle, FreeText,
|
||||
PerGameCommonOptions, OptionGroup, StartInventory, OptionList)
|
||||
PerGameCommonOptions, OptionGroup, StartInventory)
|
||||
|
||||
from .data import data
|
||||
|
||||
@@ -129,17 +129,6 @@ 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.
|
||||
@@ -881,7 +870,6 @@ class PokemonEmeraldOptions(PerGameCommonOptions):
|
||||
npc_gifts: RandomizeNpcGifts
|
||||
berry_trees: RandomizeBerryTrees
|
||||
dexsanity: Dexsanity
|
||||
dexsanity_encounter_types: DexsanityEncounterTypes
|
||||
trainersanity: Trainersanity
|
||||
item_pool_type: ItemPoolType
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ def _rename_wild_events(world: "PokemonEmeraldWorld", map_data: MapData, new_slo
|
||||
for r, sc in _encounter_subcategory_ranges[encounter_type].items()
|
||||
if i in r
|
||||
)
|
||||
subcategory_species: list[int] = []
|
||||
subcategory_species = []
|
||||
for k in subcategory_range:
|
||||
if new_slots[k] not in subcategory_species:
|
||||
subcategory_species.append(new_slots[k])
|
||||
@@ -264,12 +264,6 @@ 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
|
||||
|
||||
@@ -284,7 +278,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
|
||||
RandomizeWildPokemon.option_match_base_stats_and_type,
|
||||
}
|
||||
|
||||
already_placed: set[int] = set()
|
||||
already_placed = set()
|
||||
num_placeable_species = NUM_REAL_SPECIES - len(world.blacklisted_wilds)
|
||||
|
||||
priority_species = [data.constants["SPECIES_WAILORD"], data.constants["SPECIES_RELICANTH"]]
|
||||
@@ -355,7 +349,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
|
||||
if len(merged_blacklist) < NUM_REAL_SPECIES:
|
||||
break
|
||||
else:
|
||||
merged_blacklist = set()
|
||||
raise RuntimeError("This should never happen")
|
||||
|
||||
candidates = [
|
||||
species
|
||||
@@ -371,13 +365,11 @@ 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 new_species_id not in world.blacklisted_wilds:
|
||||
and map_name not in OUT_OF_LOGIC_MAPS:
|
||||
already_placed.add(new_species_id)
|
||||
|
||||
# Actually create the new list of slots and encounter table
|
||||
new_slots: List[int] = []
|
||||
if encounter_type in enabled_encounters:
|
||||
world.allowed_dexsanity_species.update(table.slots)
|
||||
for species_id in table.slots:
|
||||
new_slots.append(species_old_to_new_map[species_id])
|
||||
|
||||
|
||||
@@ -1548,7 +1548,7 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
|
||||
for i in range(NUM_REAL_SPECIES):
|
||||
species = data.species[NATIONAL_ID_TO_SPECIES_ID[i + 1]]
|
||||
|
||||
if species.species_id in world.blacklisted_wilds or species.species_id not in world.allowed_dexsanity_species:
|
||||
if species.species_id in world.blacklisted_wilds:
|
||||
continue
|
||||
|
||||
set_rule(
|
||||
|
||||
@@ -4,10 +4,7 @@ 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, 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
|
||||
environment_sotv_orderedstages_table, environment_sotv_table, collapse_dict_list_vertical, shift_by_offset
|
||||
|
||||
from BaseClasses import Item, ItemClassification, Tutorial
|
||||
from .options import ItemWeights, ROR2Options, ror2_option_groups
|
||||
@@ -49,7 +46,7 @@ class RiskOfRainWorld(World):
|
||||
}
|
||||
location_name_to_id = item_pickups
|
||||
|
||||
required_client_version = (0, 6, 4)
|
||||
required_client_version = (0, 5, 0)
|
||||
web = RiskOfWeb()
|
||||
total_revivals: int
|
||||
|
||||
@@ -65,9 +62,7 @@ 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_sots=bool(self.options.dlc_sots.value),
|
||||
stage_variants=bool(self.options.stage_variants)
|
||||
dlc_sotv=bool(self.options.dlc_sotv.value)
|
||||
)
|
||||
)
|
||||
self.total_revivals = int(self.options.total_revivals.value / 100 *
|
||||
@@ -76,8 +71,6 @@ 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:
|
||||
|
||||
@@ -112,39 +105,16 @@ 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)
|
||||
|
||||
if self.options.stage_variants:
|
||||
environment_offset_table = shift_by_offset(environment_vanilla_variants_table, environment_offset)
|
||||
environments_pool = {**environments_pool, **environment_offset_table}
|
||||
environments_pool = shift_by_offset(environment_vanilla_table, environment_offset)
|
||||
|
||||
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]))
|
||||
@@ -176,9 +146,7 @@ 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_sots=bool(self.options.dlc_sots.value),
|
||||
stage_variants=bool(self.options.stage_variants)
|
||||
dlc_sotv=bool(self.options.dlc_sotv.value)
|
||||
)
|
||||
)
|
||||
# Create junk items
|
||||
@@ -255,7 +223,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", "stage_variants", "show_seer_portals", casing="camel")
|
||||
"progressive_stages", casing="camel")
|
||||
return {
|
||||
**options_dict,
|
||||
"seed": "".join(self.random.choice(string.digits) for _ in range(16)),
|
||||
@@ -286,7 +254,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) or state.has("Helminth Hatchery", self.player)
|
||||
event_loc.access_rule = lambda state: state.has("Sky Meadow", self.player)
|
||||
|
||||
victory_region = self.multiworld.get_region("Victory", self.player)
|
||||
victory_event = RiskOfRainLocation(self.player, "Victory", None, victory_region)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"game": "Risk of Rain 2",
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "1.5.0",
|
||||
"authors": ["Kindasneaki"]
|
||||
}
|
||||
@@ -88,21 +88,12 @@ Explore Mode items are:
|
||||
* `Commencement`
|
||||
* `All the Hidden Realms`
|
||||
|
||||
DLC Survivors of the Void (SOTV) items
|
||||
Dlc_Sotv items
|
||||
* `Siphoned Forest`
|
||||
* `Aphelian Sanctuary`
|
||||
* `Sulfur Pools`
|
||||
* `Void Locus`
|
||||
|
||||
DLC Seekers of the Storm (SOTS) items
|
||||
|
||||
* `Shattered Abodes`, `Vicious Falls`, `Disturbed Impact`
|
||||
* `Reformed Altar`
|
||||
* `Treeborn Colony`, `Golden Dieback`
|
||||
* `Prime Meridian`
|
||||
* `Helminth Hatchery`
|
||||
|
||||
|
||||
When an explore item is granted, it will unlock that environment and will now be accessible! The
|
||||
game will still pick randomly which environment is next, but it will first check to see if they are available. If you have
|
||||
multiple of the next environments unlocked, it will weight the game to have a ***higher chance*** to go to one you
|
||||
|
||||
@@ -23,13 +23,6 @@ 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
|
||||
@@ -66,7 +59,6 @@ also optionally connect to the multiworld using the text client, which can be fo
|
||||
|
||||
### In-Game Commands
|
||||
These commands are to be used in-game by using ``Ctrl + Alt + ` `` and then typing the following:
|
||||
- `archipelago_reconnect` Reconnect to AP.
|
||||
- `archipelago_connect <url> <port> <slot> [password]` example: "archipelago_connect archipelago.gg 38281 SlotName".
|
||||
- `archipelago_deathlink true/false` Toggle deathlink.
|
||||
- `archipelago_disconnect` Disconnect from AP.
|
||||
|
||||
@@ -3,8 +3,7 @@ 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_sost_orderedstages_table, \
|
||||
environment_sots_variants_orderedstages_table, environment_vanilla_variant_orderedstages_table
|
||||
environment_sotv_orderedstages_table
|
||||
|
||||
|
||||
class RiskOfRainLocation(Location):
|
||||
@@ -58,20 +57,13 @@ 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,
|
||||
dlc_sots: bool, stage_variants: bool) \
|
||||
def get_locations(chests: int, shrines: int, scavengers: int, scanners: int, altars: int, dlc_sotv: 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(
|
||||
@@ -94,6 +86,4 @@ location_table.update(get_locations(
|
||||
scanners=ScannersPerEnvironment.range_end,
|
||||
altars=AltarsPerEnvironment.range_end,
|
||||
dlc_sotv=True,
|
||||
dlc_sots=True,
|
||||
stage_variants=True
|
||||
))
|
||||
|
||||
@@ -22,9 +22,8 @@ class Goal(Choice):
|
||||
class Victory(Choice):
|
||||
"""
|
||||
Mithrix: Defeat Mithrix in Commencement
|
||||
Voidling: Defeat the Voidling in The Planetarium (SOTV DLC required! Will select any if not enabled.)
|
||||
Voidling: Defeat the Voidling in The Planetarium (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"
|
||||
@@ -32,7 +31,6 @@ class Victory(Choice):
|
||||
option_mithrix = 1
|
||||
option_voidling = 2
|
||||
option_limbo = 3
|
||||
option_falseson = 4
|
||||
default = 0
|
||||
|
||||
|
||||
@@ -140,26 +138,18 @@ 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: falseson - only dying in Prime Meridian will count."""
|
||||
Victory: limbo - Obliterating yourself will count."""
|
||||
display_name = "Final Stage Death is Win"
|
||||
|
||||
|
||||
class DLC_SOTV(Toggle):
|
||||
"""
|
||||
Enable if you are using Survivors of the Void DLC.
|
||||
Enable if you are using SOTV 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."""
|
||||
@@ -172,23 +162,6 @@ 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.
|
||||
|
||||
@@ -411,8 +384,6 @@ ror2_option_groups = [
|
||||
AltarsPerEnvironment,
|
||||
RequireStages,
|
||||
ProgressiveStages,
|
||||
StageVariants,
|
||||
ShowSeerPortals,
|
||||
]),
|
||||
OptionGroup("Classic Mode Options", [
|
||||
TotalLocations,
|
||||
@@ -456,11 +427,8 @@ class ROR2Options(PerGameCommonOptions):
|
||||
start_with_revive: StartWithRevive
|
||||
final_stage_death: FinalStageDeath
|
||||
dlc_sotv: DLC_SOTV
|
||||
dlc_sots: DLC_SOTS
|
||||
require_stages: RequireStages
|
||||
progressive_stages: ProgressiveStages
|
||||
stage_variants: StageVariants
|
||||
show_seer_portals: ShowSeerPortals
|
||||
death_link: DeathLink
|
||||
item_pickup_step: ItemPickupStep
|
||||
shrine_use_step: ShrineUseStep
|
||||
|
||||
@@ -18,10 +18,13 @@ 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", "Titanic Plains",
|
||||
"Menu": RoRRegionData(None, ["Distant Roost", "Distant Roost (2)",
|
||||
"Titanic Plains", "Titanic Plains (2)",
|
||||
"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"]),
|
||||
@@ -32,30 +35,12 @@ 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_sotv_regions: Dict[str, RoRRegionData] = {
|
||||
dlc_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",
|
||||
@@ -76,15 +61,10 @@ 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_sotv_other_regions: Dict[str, RoRRegionData] = {
|
||||
dlc_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)
|
||||
@@ -92,14 +72,8 @@ 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.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)
|
||||
all_location_regions = {**non_dlc_regions, **dlc_regions}
|
||||
|
||||
# Locations
|
||||
for key in all_location_regions:
|
||||
@@ -125,52 +99,25 @@ 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}
|
||||
|
||||
# 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
|
||||
# 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")
|
||||
|
||||
# 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)
|
||||
regions_pool: Dict = {**all_location_regions, **other_regions, **dlc_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_sotv_other_regions["The Planetarium"].region_exits.pop(0)
|
||||
dlc_sost_other_regions["Prime Meridian"].region_exits.pop(0)
|
||||
dlc_other_regions["The Planetarium"].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_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)
|
||||
dlc_other_regions["The Planetarium"].region_exits.pop(0)
|
||||
|
||||
# Create all the regions
|
||||
for name, data in regions_pool.items():
|
||||
|
||||
@@ -4,14 +4,11 @@ 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
|
||||
@@ -57,34 +54,6 @@ 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")
|
||||
|
||||
@@ -131,32 +100,18 @@ 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_all_table = {**environment_vanilla_table, **environment_sotv_table, **environment_sost_table,
|
||||
**environment_vanilla_variants_table, **environment_sots_variants_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}
|
||||
|
||||
|
||||
def shift_by_offset(dictionary: Dict[str, int], offset: int) -> Dict[str, int]:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from worlds.generic.Rules import set_rule, add_rule
|
||||
from BaseClasses import MultiWorld
|
||||
from .locations import get_locations
|
||||
from .ror2environments import environment_vanilla_orderedstages_table, environment_sotv_orderedstages_table, \
|
||||
environment_sost_orderedstages_table, environment_vanilla_variant_orderedstages_table, \
|
||||
environment_sots_variants_orderedstages_table
|
||||
from .ror2environments import environment_vanilla_orderedstages_table, environment_sotv_orderedstages_table
|
||||
from typing import Set, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -45,24 +43,6 @@ def has_location_access_rule(multiworld: MultiWorld, environment: str, player: i
|
||||
multiworld.get_location(location_name, player).access_rule = \
|
||||
lambda state: state.has(environment, player)
|
||||
|
||||
def explore_environment_location_rules(table, multiworld, player, chests, shrines, newts, scavengers, scanners):
|
||||
for i in range(len(table)):
|
||||
for environment_name, _ in table[i].items():
|
||||
# Make sure to go through each location
|
||||
if scavengers == 1:
|
||||
has_location_access_rule(multiworld, environment_name, player, scavengers, "Scavenger")
|
||||
if scanners == 1:
|
||||
has_location_access_rule(multiworld, environment_name, player, scanners, "Radio Scanner")
|
||||
for chest in range(1, chests + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, chest, "Chest")
|
||||
for shrine in range(1, shrines + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, shrine, "Shrine")
|
||||
if newts > 0:
|
||||
for newt in range(1, newts + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, newt, "Newt Altar")
|
||||
if i > 0:
|
||||
has_stage_access_rule(multiworld, f"Stage {i}", i, environment_name, player)
|
||||
|
||||
|
||||
def set_rules(ror2_world: "RiskOfRainWorld") -> None:
|
||||
player = ror2_world.player
|
||||
@@ -80,9 +60,7 @@ def set_rules(ror2_world: "RiskOfRainWorld") -> None:
|
||||
scavengers=ror2_options.scavengers_per_stage.value,
|
||||
scanners=ror2_options.scanner_per_stage.value,
|
||||
altars=ror2_options.altars_per_stage.value,
|
||||
dlc_sotv=bool(ror2_options.dlc_sotv.value),
|
||||
dlc_sots=bool(ror2_options.dlc_sots.value),
|
||||
stage_variants=bool(ror2_options.stage_variants)
|
||||
dlc_sotv=bool(ror2_options.dlc_sotv.value)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -123,25 +101,40 @@ def set_rules(ror2_world: "RiskOfRainWorld") -> None:
|
||||
newts = ror2_options.altars_per_stage.value
|
||||
scavengers = ror2_options.scavengers_per_stage.value
|
||||
scanners = ror2_options.scanner_per_stage.value
|
||||
# Vanilla stages
|
||||
explore_environment_location_rules(environment_vanilla_orderedstages_table, multiworld, player, chests, shrines, newts,
|
||||
scavengers, scanners)
|
||||
# Vanilla Variant stages
|
||||
if ror2_options.stage_variants:
|
||||
explore_environment_location_rules(environment_vanilla_variant_orderedstages_table, multiworld, player, chests, shrines, newts,
|
||||
scavengers, scanners)
|
||||
# SoTv stages
|
||||
if ror2_options.dlc_sotv:
|
||||
explore_environment_location_rules(environment_sotv_orderedstages_table, multiworld, player, chests, shrines,
|
||||
newts, scavengers, scanners)
|
||||
# SoTS stages
|
||||
if ror2_options.dlc_sots:
|
||||
explore_environment_location_rules(environment_sost_orderedstages_table, multiworld, player, chests, shrines,
|
||||
newts, scavengers, scanners)
|
||||
if ror2_options.dlc_sots and ror2_options.stage_variants:
|
||||
explore_environment_location_rules(environment_sots_variants_orderedstages_table, multiworld, player, chests, shrines,
|
||||
newts, scavengers, scanners)
|
||||
for i in range(len(environment_vanilla_orderedstages_table)):
|
||||
for environment_name, _ in environment_vanilla_orderedstages_table[i].items():
|
||||
# Make sure to go through each location
|
||||
if scavengers == 1:
|
||||
has_location_access_rule(multiworld, environment_name, player, scavengers, "Scavenger")
|
||||
if scanners == 1:
|
||||
has_location_access_rule(multiworld, environment_name, player, scanners, "Radio Scanner")
|
||||
for chest in range(1, chests + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, chest, "Chest")
|
||||
for shrine in range(1, shrines + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, shrine, "Shrine")
|
||||
if newts > 0:
|
||||
for newt in range(1, newts + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, newt, "Newt Altar")
|
||||
if i > 0:
|
||||
has_stage_access_rule(multiworld, f"Stage {i}", i, environment_name, player)
|
||||
|
||||
if ror2_options.dlc_sotv:
|
||||
for i in range(len(environment_sotv_orderedstages_table)):
|
||||
for environment_name, _ in environment_sotv_orderedstages_table[i].items():
|
||||
# Make sure to go through each location
|
||||
if scavengers == 1:
|
||||
has_location_access_rule(multiworld, environment_name, player, scavengers, "Scavenger")
|
||||
if scanners == 1:
|
||||
has_location_access_rule(multiworld, environment_name, player, scanners, "Radio Scanner")
|
||||
for chest in range(1, chests + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, chest, "Chest")
|
||||
for shrine in range(1, shrines + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, shrine, "Shrine")
|
||||
if newts > 0:
|
||||
for newt in range(1, newts + 1):
|
||||
has_location_access_rule(multiworld, environment_name, player, newt, "Newt Altar")
|
||||
if i > 0:
|
||||
has_stage_access_rule(multiworld, f"Stage {i}", i, environment_name, player)
|
||||
has_entrance_access_rule(multiworld, "Hidden Realm: A Moment, Fractured", "Hidden Realm: A Moment, Whole",
|
||||
player)
|
||||
has_stage_access_rule(multiworld, "Stage 1", 1, "Hidden Realm: Bazaar Between Time", player)
|
||||
@@ -154,8 +147,6 @@ def set_rules(ror2_world: "RiskOfRainWorld") -> None:
|
||||
has_entrance_access_rule(multiworld, "Stage 5", "Void Locus", player)
|
||||
if ror2_options.victory == "voidling":
|
||||
has_all_items(multiworld, {"Stage 5", "The Planetarium"}, "Commencement", player)
|
||||
if ror2_options.dlc_sots:
|
||||
has_entrance_access_rule(multiworld, "Stage 5", "Prime Meridian", player)
|
||||
|
||||
# Win Condition
|
||||
multiworld.completion_condition[player] = lambda state: state.has("Victory", player)
|
||||
|
||||
@@ -4,33 +4,23 @@ from . import RoR2TestBase
|
||||
class DLCTest(RoR2TestBase):
|
||||
options = {
|
||||
"dlc_sotv": "true",
|
||||
"victory": "any",
|
||||
"dlc_sots": "true",
|
||||
"victory": "any"
|
||||
}
|
||||
|
||||
def test_commencement_victory(self) -> None:
|
||||
self.collect_all_but(["Commencement", "The Planetarium", "Hidden Realm: A Moment, Whole", "Prime Meridian",
|
||||
"Victory"])
|
||||
self.collect_all_but(["Commencement", "The Planetarium", "Hidden Realm: A Moment, Whole", "Victory"])
|
||||
self.assertBeatable(False)
|
||||
self.collect_by_name("Commencement")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_planetarium_victory(self) -> None:
|
||||
self.collect_all_but(["Commencement", "The Planetarium", "Hidden Realm: A Moment, Whole", "Prime Meridian",
|
||||
"Victory"])
|
||||
self.collect_all_but(["Commencement", "The Planetarium", "Hidden Realm: A Moment, Whole", "Victory"])
|
||||
self.assertBeatable(False)
|
||||
self.collect_by_name("The Planetarium")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_moment_whole_victory(self) -> None:
|
||||
self.collect_all_but(["Commencement", "The Planetarium", "Hidden Realm: A Moment, Whole", "Prime Meridian",
|
||||
"Victory"])
|
||||
self.collect_all_but(["Commencement", "The Planetarium", "Hidden Realm: A Moment, Whole", "Victory"])
|
||||
self.assertBeatable(False)
|
||||
self.collect_by_name("Hidden Realm: A Moment, Whole")
|
||||
self.assertBeatable(True)
|
||||
def test_false_son_victory(self) -> None:
|
||||
self.collect_all_but(["Commencement", "The Planetarium", "Hidden Realm: A Moment, Whole", "Prime Meridian",
|
||||
"Victory"])
|
||||
self.assertBeatable(False)
|
||||
self.collect_by_name("Prime Meridian")
|
||||
self.assertBeatable(True)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from . import RoR2TestBase
|
||||
|
||||
|
||||
class FalseSonGoalTest(RoR2TestBase):
|
||||
options = {
|
||||
"dlc_sots": "true",
|
||||
"victory": "falseson",
|
||||
"stage_variants": "true"
|
||||
}
|
||||
|
||||
def test_false_son(self) -> None:
|
||||
self.collect_all_but(["Prime Meridian", "Victory"])
|
||||
self.assertFalse(self.can_reach_region("Prime Meridian"))
|
||||
self.assertBeatable(False)
|
||||
self.collect_by_name("Prime Meridian")
|
||||
self.assertTrue(self.can_reach_region("Prime Meridian"))
|
||||
self.assertBeatable(True)
|
||||
@@ -1,5 +1,4 @@
|
||||
import argparse
|
||||
import ssl
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
@@ -9,13 +8,12 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import certifi
|
||||
import requests
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess # nosec
|
||||
import subprocess
|
||||
from tkinter import messagebox
|
||||
from typing import Any, Dict, Set, List
|
||||
from typing import Any, Dict, Set
|
||||
import urllib
|
||||
import urllib.parse
|
||||
|
||||
@@ -92,7 +90,7 @@ def get_timestamp(date: str) -> float:
|
||||
|
||||
def send_request(request_url: str) -> UrlResponse:
|
||||
"""Fetches status code and json response from given url"""
|
||||
response = requests.get(request_url, timeout=10)
|
||||
response = requests.get(request_url)
|
||||
if response.status_code == 200: # success
|
||||
try:
|
||||
data = response.json()
|
||||
@@ -131,16 +129,13 @@ def update(target_asset: str, url: str) -> bool:
|
||||
if update_available and messagebox.askyesnocancel(f"New {target_asset}",
|
||||
"Would you like to install the new version now?"):
|
||||
# unzip and patch
|
||||
if not release_url.lower().startswith("https"):
|
||||
raise ValueError(f'Unexpected scheme for url "{release_url}".')
|
||||
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where())
|
||||
with urllib.request.urlopen(release_url, context=context) as download: # nosec
|
||||
with urllib.request.urlopen(release_url) as download:
|
||||
with zipfile.ZipFile(BytesIO(download.read())) as zf:
|
||||
zf.extractall()
|
||||
patch_game()
|
||||
set_date(target_asset, newest_date)
|
||||
except (ValueError, RuntimeError, urllib.error.HTTPError, urllib.error.URLError) as e:
|
||||
update_error = f"Failed to apply update:\n{e}"
|
||||
except (ValueError, RuntimeError, urllib.error.HTTPError):
|
||||
update_error = f"Failed to apply update."
|
||||
messagebox.showerror("Failure", update_error)
|
||||
raise RuntimeError(update_error)
|
||||
return True
|
||||
@@ -163,8 +158,8 @@ def is_install_valid() -> bool:
|
||||
if not os.path.exists(file_name):
|
||||
return False
|
||||
with open(file_name, "rb") as clean:
|
||||
current_hash = hashlib.md5(clean.read(), usedforsecurity=False).hexdigest()
|
||||
if current_hash != expected_hash:
|
||||
current_hash = hashlib.md5(clean.read()).hexdigest()
|
||||
if not secrets.compare_digest(current_hash, expected_hash):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -194,16 +189,12 @@ def install() -> None:
|
||||
|
||||
logging.info("Extracting files from cab archive.")
|
||||
if Utils.is_windows:
|
||||
windows_path = os.environ["WINDIR"]
|
||||
extractor_path = f"{windows_path}/System32/Extrac32"
|
||||
subprocess.run([extractor_path, "/Y", "/E", "saving_princess.cab"]) #nosec
|
||||
subprocess.run(["Extrac32", "/Y", "/E", "saving_princess.cab"])
|
||||
else:
|
||||
wine_path = shutil.which("wine")
|
||||
p7zip_path = shutil.which("7z")
|
||||
if wine_path is not None:
|
||||
subprocess.run([wine_path, "Extrac32", "/Y", "/E", "saving_princess.cab"]) #nosec
|
||||
elif p7zip_path is not None:
|
||||
subprocess.run([p7zip_path, "e", "saving_princess.cab"]) #nosec
|
||||
if shutil.which("wine") is not None:
|
||||
subprocess.run(["wine", "Extrac32", "/Y", "/E", "saving_princess.cab"])
|
||||
elif shutil.which("7z") is not None:
|
||||
subprocess.run(["7z", "e", "saving_princess.cab"])
|
||||
else:
|
||||
error = "Could not find neither wine nor 7z.\n\nPlease install either the wine or the p7zip package."
|
||||
messagebox.showerror("Missing package!", f"Error: {error}")
|
||||
@@ -259,10 +250,7 @@ def launch(*args: str) -> Any:
|
||||
if SavingPrincessWorld.settings.launch_game:
|
||||
logging.info("Launching game.")
|
||||
try:
|
||||
game: str = os.path.join(os.getcwd(), "Saving Princess v0_8.exe")
|
||||
launch_command: List[str] = (SavingPrincessWorld.settings.launch_command_with_args
|
||||
+ [game, name, password, server])
|
||||
subprocess.Popen(launch_command) # nosec
|
||||
subprocess.Popen(f"{SavingPrincessWorld.settings.launch_command} {name} {password} {server}")
|
||||
except FileNotFoundError:
|
||||
error = ("Could not run the game!\n\n"
|
||||
"Please check that launch_command in options.yaml or host.yaml is set up correctly.")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import shutil
|
||||
from typing import ClassVar, Dict, Any, Type, List, Union
|
||||
|
||||
import Utils
|
||||
@@ -21,15 +20,6 @@ components.append(
|
||||
)
|
||||
|
||||
|
||||
def get_default_launch_command() -> List[str]:
|
||||
"""Returns platform-dependant default launch command for Saving Princess"""
|
||||
if Utils.is_windows:
|
||||
return []
|
||||
else:
|
||||
wine_path = shutil.which("wine")
|
||||
return [wine_path] if wine_path is not None else ["/usr/bin/wine"]
|
||||
|
||||
|
||||
class SavingPrincessSettings(Group):
|
||||
class GamePath(UserFilePath):
|
||||
"""Path to the game executable from which files are extracted"""
|
||||
@@ -44,17 +34,17 @@ class SavingPrincessSettings(Group):
|
||||
class LaunchGame(Bool):
|
||||
"""Set this to false to never autostart the game"""
|
||||
|
||||
class LaunchCommandWithArgs(List[str]):
|
||||
class LaunchCommand(str):
|
||||
"""
|
||||
The console command that will be used to launch the game
|
||||
The command will be executed with the installation folder as the current directory
|
||||
Additional items in the list will be passed in as arguments
|
||||
"""
|
||||
|
||||
exe_path: GamePath = GamePath("Saving Princess.exe")
|
||||
install_folder: InstallFolder = InstallFolder("Saving Princess")
|
||||
launch_game: Union[LaunchGame, bool] = True
|
||||
launch_command_with_args: LaunchCommandWithArgs = LaunchCommandWithArgs(get_default_launch_command())
|
||||
launch_command: LaunchCommand = LaunchCommand('"Saving Princess v0_8.exe"' if Utils.is_windows
|
||||
else 'wine "Saving Princess v0_8.exe"')
|
||||
|
||||
|
||||
class SavingPrincessWeb(WebWorld):
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"game": "Saving Princess",
|
||||
"authors": [ "LeonarthCG" ],
|
||||
"minimum_ap_version": "0.6.6",
|
||||
"world_version": "1.0.0"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user