Merge branch 'ArchipelagoMW:main' into main

This commit is contained in:
CookieCat
2023-12-16 19:31:56 -05:00
committed by GitHub
48 changed files with 483 additions and 286 deletions

View File

@@ -115,11 +115,12 @@ class AdventureContext(CommonContext):
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]

View File

@@ -252,15 +252,20 @@ class MultiWorld():
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.")
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
for option_key in world_type.options_dataclass.type_hints:
option_values = getattr(args, option_key, {})
setattr(self, option_key, option_values)
# TODO - remove this loop once all worlds use options dataclasses
options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
@@ -491,7 +496,7 @@ class MultiWorld():
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, starting_state: Optional[CollectionState] = None):
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -504,7 +509,7 @@ class MultiWorld():
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere = set()
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
@@ -524,12 +529,19 @@ class MultiWorld():
return False
def get_spheres(self):
def get_spheres(self) -> Iterator[Set[Location]]:
"""
yields a set of locations for each logical sphere
If there are unreachable locations, the last sphere of reachable
locations is followed by an empty set, and then a set of all of the
unreachable locations.
"""
state = CollectionState(self)
locations = set(self.get_filled_locations())
while locations:
sphere = set()
sphere: Set[Location] = set()
for location in locations:
if location.can_reach(state):

41
Fill.py
View File

@@ -550,7 +550,7 @@ def flood_items(world: MultiWorld) -> None:
break
def balance_multiworld_progression(world: MultiWorld) -> None:
def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
@@ -558,28 +558,28 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
player: world.worlds[player].options.progression_balancing / 100
for player in world.player_ids
if world.worlds[player].options.progression_balancing > 0
player: multiworld.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
state: CollectionState = CollectionState(world)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(world.get_locations())
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
for location in multiworld.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
for player in multiworld.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
@@ -658,7 +658,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
balancing_unchecked_locations.remove(location)
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
if multiworld.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
@@ -675,7 +675,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
world.random.shuffle(items_to_test)
multiworld.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
@@ -687,8 +687,8 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
reducing_state.sweep_for_events(locations=locations_to_test)
if world.has_beaten_game(balancing_state):
if not world.has_beaten_game(reducing_state):
if multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -696,33 +696,32 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
replaced_items = False
old_moved_item_count = moved_item_count
# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
world.random.shuffle(replacement_locations)
multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
world.random.shuffle(items_to_replace)
multiworld.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
for new_location in replacement_locations:
for i, new_location in enumerate(replacement_locations):
if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
replacement_locations.remove(new_location)
replacement_locations.pop(i)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
replaced_items = True
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")
if replaced_items:
if old_moved_item_count < moved_item_count:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
@@ -736,7 +735,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
state.collect(location.item, True, location)
checked_locations |= sphere_locations
if world.has_beaten_game(state):
if multiworld.has_beaten_game(state):
break
elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.")

11
Main.py
View File

@@ -117,6 +117,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = world.early_items[player].get(item_name, 0)
if early:
world.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = world.early_local_items[player].get(item_name, 0)
if local_early:
world.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")

View File

@@ -779,6 +779,25 @@ def deprecate(message: str):
import warnings
warnings.warn(message)
class DeprecateDict(dict):
log_message: str
should_error: bool
def __init__(self, message, error: bool = False) -> None:
self.log_message = message
self.should_error = error
super().__init__()
def __getitem__(self, item: Any) -> Any:
if self.should_error:
deprecate(self.log_message)
elif __debug__:
import warnings
warnings.warn(self.log_message)
return super().__getitem__(item)
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327

View File

@@ -1,3 +1,4 @@
import os
import zipfile
import base64
from typing import Union, Dict, Set, Tuple
@@ -6,13 +7,7 @@ from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
from WebHostLib import app
banned_zip_contents = (".sfc",)
def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
@@ -51,33 +46,41 @@ def mysterycheck():
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
for uploaded_file in files:
# if user does not select file, browser also
# submit an empty part without filename
if uploaded_file.filename == '':
return 'No selected file'
if banned_file(uploaded_file.filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
"Your file was deleted.")
# If the user does not select file, the browser will still submit an empty string without a file name.
elif uploaded_file.filename == "":
return "No selected file."
elif uploaded_file.filename in options:
return f'Conflicting files named {uploaded_file.filename} submitted'
elif uploaded_file and allowed_file(uploaded_file.filename):
return f"Conflicting files named {uploaded_file.filename} submitted."
elif uploaded_file and allowed_options(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):
if not zipfile.is_zipfile(uploaded_file):
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
with zipfile.ZipFile(uploaded_file, 'r') as zfile:
infolist = zfile.infolist()
uploaded_file.seek(0) # offset from is_zipfile check
with zipfile.ZipFile(uploaded_file, "r") as zfile:
for file in zfile.infolist():
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
base_filename = os.path.basename(file.filename)
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return ("Uploaded data contained a rom file, "
"which is likely to contain copyrighted material. "
"Your file was deleted.")
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
if base_filename.endswith(".archipelago"):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
elif base_filename.endswith(".zip"):
return "Nested .zip files inside a .zip are not supported."
elif banned_file(base_filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
"material. Your file was deleted.")
# Ignore dot-files.
elif not base_filename.startswith(".") and allowed_options(base_filename):
options[file.filename] = zfile.open(file, "r").read()
else:
options[uploaded_file.filename] = uploaded_file.read()
if not options:
return "Did not find a .yaml file to process."
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
return options

View File

@@ -5,5 +5,5 @@ Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.2.2; python_version >= '3.9'
bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3

View File

@@ -19,7 +19,22 @@ from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot, GameDataPackage
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb")
banned_extensions = (".sfc", ".z64", ".n64", ".nes", ".smc", ".sms", ".gb", ".gbc", ".gba")
allowed_options_extensions = (".yaml", ".json", ".yml", ".txt", ".zip")
allowed_generation_extensions = (".archipelago", ".zip")
def allowed_options(filename: str) -> bool:
return filename.endswith(allowed_options_extensions)
def allowed_generation(filename: str) -> bool:
return filename.endswith(allowed_generation_extensions)
def banned_file(filename: str) -> bool:
return filename.endswith(banned_extensions)
def process_multidata(compressed_multidata, files={}):
decompressed_multidata = MultiServer.Context.decompress(compressed_multidata)
@@ -61,8 +76,8 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
if not owner:
owner = session["_id"]
infolist = zfile.infolist()
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist):
flash(Markup("Error: Your .zip file only contains .yaml files. "
if all(allowed_options(file.filename) or file.is_dir() for file in infolist):
flash(Markup("Error: Your .zip file only contains options files. "
'Did you mean to <a href="/generate">generate a game</a>?'))
return
@@ -73,7 +88,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# Load files.
for file in infolist:
handler = AutoPatchRegister.get_handler(file.filename)
if file.filename.endswith(banned_zip_contents):
if banned_file(file.filename):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
@@ -136,35 +151,34 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("No multidata was found in the zip file, which is required.")
@app.route('/uploads', methods=['GET', 'POST'])
@app.route("/uploads", methods=["GET", "POST"])
def uploads():
if request.method == 'POST':
# check if the post request has the file part
if 'file' not in request.files:
flash('No file part')
if request.method == "POST":
# check if the POST request has a file part.
if "file" not in request.files:
flash("No file part in POST request.")
else:
file = request.files['file']
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
flash('No selected file')
elif file and allowed_file(file.filename):
if zipfile.is_zipfile(file):
with zipfile.ZipFile(file, 'r') as zfile:
uploaded_file = request.files["file"]
# If the user does not select file, the browser will still submit an empty string without a file name.
if uploaded_file.filename == "":
flash("No selected file.")
elif uploaded_file and allowed_generation(uploaded_file.filename):
if zipfile.is_zipfile(uploaded_file):
with zipfile.ZipFile(uploaded_file, "r") as zfile:
try:
res = upload_zip_to_db(zfile)
except VersionException:
flash(f"Could not load multidata. Wrong Version detected.")
else:
if type(res) == str:
if res is str:
return res
elif res:
return redirect(url_for("view_seed", seed=res.id))
else:
file.seek(0) # offset from is_zipfile check
uploaded_file.seek(0) # offset from is_zipfile check
# noinspection PyBroadException
try:
multidata = file.read()
multidata = uploaded_file.read()
slots, multidata = process_multidata(multidata)
except Exception as e:
flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})")
@@ -182,7 +196,3 @@ def user_content():
rooms = select(room for room in Room if room.owner == session["_id"])
seeds = select(seed for seed in Seed if seed.owner == session["_id"])
return render_template("userContent.html", rooms=rooms, seeds=seeds)
def allowed_file(filename):
return filename.endswith(('.archipelago', ".zip"))

View File

@@ -585,7 +585,7 @@ else
-- misaligned, so for GB and GBC we explicitly set the callback on
-- vblank instead.
-- https://github.com/TASEmulators/BizHawk/issues/3711
if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" then
if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" or emu.getsystemid() == "SGB" then
event.onmemoryexecute(tick, 0x40, "tick", "System Bus")
else
event.onframeend(tick)

View File

@@ -1,13 +1,13 @@
colorama>=0.4.5
websockets>=11.0.3
websockets>=12.0
PyYAML>=6.0.1
jellyfish>=1.0.3
jinja2>=3.1.2
schema>=0.7.5
kivy>=2.2.0
kivy>=2.2.1
bsdiff4>=1.2.4
platformdirs>=4.0.0
certifi>=2023.11.17
cython>=3.0.5
cython>=3.0.6
cymem>=2.0.8
orjson>=3.9.10

View File

@@ -1,5 +1,8 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from Fill import distribute_items_restrictive
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
class TestIDs(unittest.TestCase):
@@ -66,3 +69,34 @@ class TestIDs(unittest.TestCase):
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))
def test_postgen_datapackage(self):
"""Generates a solo multiworld and checks that the datapackage is still valid"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
multiworld = setup_solo_multiworld(world_type)
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
datapackage = world_type.get_data_package_data()
for item_group, item_names in datapackage["item_name_groups"].items():
self.assertIsInstance(item_group, str,
f"item_name_group names should be strings: {item_group}")
for item_name in item_names:
self.assertIsInstance(item_name, str,
f"{item_name}, in group {item_group} is not a string")
for loc_group, loc_names in datapackage["location_name_groups"].items():
self.assertIsInstance(loc_group, str,
f"location_name_group names should be strings: {loc_group}")
for loc_name in loc_names:
self.assertIsInstance(loc_name, str,
f"{loc_name}, in group {loc_group} is not a string")
for item_name, item_id in datapackage["item_name_to_id"].items():
self.assertIsInstance(item_name, str,
f"{item_name} is not a valid item name for item_name_to_id")
self.assertIsInstance(item_id, int,
f"{item_id} for {item_name} should be an int")
for loc_name, loc_id in datapackage["location_name_to_id"].items():
self.assertIsInstance(loc_name, str,
f"{loc_name} is not a valid item name for location_name_to_id")
self.assertIsInstance(loc_id, int,
f"{loc_id} for {loc_name} should be an int")

View File

@@ -77,6 +77,10 @@ class AutoWorldRegister(type):
# create missing options_dataclass from legacy option_definitions
# TODO - remove this once all worlds use options dataclasses
if "options_dataclass" not in dct and "option_definitions" in dct:
# TODO - switch to deprecate after a version
if __debug__:
from warnings import warn
warn("Assigning options through option_definitions is now deprecated. Use options_dataclass instead.")
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
bases=(PerGameCommonOptions,))

View File

@@ -264,7 +264,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
if loc in all_state_base.events:
all_state_base.events.remove(loc)
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True,
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True, allow_excluded=True,
name="LttP Dungeon Items")

View File

@@ -682,8 +682,6 @@ def get_pool_core(world, player: int):
key_location = world.random.choice(key_locations)
place_item(key_location, "Small Key (Universal)")
pool = pool[:-3]
if world.key_drop_shuffle[player]:
pass # pool.extend([item_to_place] * (len(key_drop_data) - 1))
return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_icon,
additional_pieces_to_place)

View File

@@ -136,7 +136,8 @@ def mirrorless_path_to_castle_courtyard(world, player):
def set_defeat_dungeon_boss_rule(location):
# Lambda required to defer evaluation of dungeon.boss since it will change later if boss shuffle is used
set_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
def set_always_allow(spot, rule):
spot.always_allow = rule

View File

@@ -289,12 +289,17 @@ class ALTTPWorld(World):
self.waterfall_fairy_bottle_fill = self.random.choice(bottle_options)
self.pyramid_fairy_bottle_fill = self.random.choice(bottle_options)
if multiworld.mode[player] == 'standard' \
and multiworld.smallkey_shuffle[player] \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_universal \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_own_dungeons \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_start_with:
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
if multiworld.mode[player] == 'standard':
if multiworld.smallkey_shuffle[player]:
if (multiworld.smallkey_shuffle[player] not in
(smallkey_shuffle.option_universal, smallkey_shuffle.option_own_dungeons,
smallkey_shuffle.option_start_with)):
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
self.multiworld.local_items[self.player].value.add("Small Key (Hyrule Castle)")
self.multiworld.non_local_items[self.player].value.discard("Small Key (Hyrule Castle)")
if multiworld.bigkey_shuffle[player]:
self.multiworld.local_items[self.player].value.add("Big Key (Hyrule Castle)")
self.multiworld.non_local_items[self.player].value.discard("Big Key (Hyrule Castle)")
# system for sharing ER layouts
self.er_seed = str(multiworld.random.randint(0, 2 ** 64))

View File

@@ -5,7 +5,7 @@ import os
import shutil
import threading
import zipfile
from typing import Optional, TYPE_CHECKING, Any, List, Callable, Tuple
from typing import Optional, TYPE_CHECKING, Any, List, Callable, Tuple, Union
import jinja2
@@ -63,7 +63,7 @@ recipe_time_ranges = {
class FactorioModFile(worlds.Files.APContainer):
game = "Factorio"
compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives
writing_tasks: List[Callable[[], Tuple[str, str]]]
writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]]
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
@@ -164,9 +164,7 @@ def generate_mod(world: "Factorio", output_directory: str):
template_data["free_sample_blacklist"].update({item: 1 for item in multiworld.free_sample_blacklist[player].value})
template_data["free_sample_blacklist"].update({item: 0 for item in multiworld.free_sample_whitelist[player].value})
mod_dir = os.path.join(output_directory, versioned_mod_name)
zf_path = os.path.join(mod_dir + ".zip")
zf_path = os.path.join(output_directory, versioned_mod_name + ".zip")
mod = FactorioModFile(zf_path, player=player, player_name=multiworld.player_name[player])
if world.zip_path:
@@ -177,7 +175,13 @@ def generate_mod(world: "Factorio", output_directory: str):
mod.writing_tasks.append(lambda arcpath=versioned_mod_name+"/"+path_part, content=zf.read(file):
(arcpath, content))
else:
shutil.copytree(os.path.join(os.path.dirname(__file__), "data", "mod"), mod_dir, dirs_exist_ok=True)
basepath = os.path.join(os.path.dirname(__file__), "data", "mod")
for dirpath, dirnames, filenames in os.walk(basepath):
base_arc_path = versioned_mod_name+"/"+os.path.relpath(dirpath, basepath)
for filename in filenames:
mod.writing_tasks.append(lambda arcpath=base_arc_path+"/"+filename,
file_path=os.path.join(dirpath, filename):
(arcpath, open(file_path, "rb").read()))
mod.writing_tasks.append(lambda: (versioned_mod_name + "/data.lua",
data_template.render(**template_data)))
@@ -197,5 +201,3 @@ def generate_mod(world: "Factorio", output_directory: str):
# write the mod file
mod.write()
# clean up
shutil.rmtree(mod_dir)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import typing
import datetime
from Options import Choice, OptionDict, OptionSet, ItemDict, Option, DefaultOnToggle, Range, DeathLink, Toggle, \
from Options import Choice, OptionDict, OptionSet, Option, DefaultOnToggle, Range, DeathLink, Toggle, \
StartInventoryPool
from schema import Schema, Optional, And, Or
@@ -207,10 +207,9 @@ class RecipeIngredientsOffset(Range):
range_end = 5
class FactorioStartItems(ItemDict):
class FactorioStartItems(OptionDict):
"""Mapping of Factorio internal item-name to amount granted on start."""
display_name = "Starting Items"
verify_item_name = False
default = {"burner-mining-drill": 19, "stone-furnace": 19}

View File

@@ -267,11 +267,11 @@ class CompanionLevelingType(Choice):
class CompanionSpellbookType(Choice):
"""Update companions' spellbook.
Standard: Original game spellbooks.
Standard Extended: Add some extra spells. Tristam gains Exit and Quake and Reuben gets Blizzard.
Extended: Add some extra spells. Tristam gains Exit and Quake and Reuben gets Blizzard.
Random Balanced: Randomize the spellbooks with an appropriate mix of spells.
Random Chaos: Randomize the spellbooks in total free-for-all."""
option_standard = 0
option_standard_extended = 1
option_extended = 1
option_random_balanced = 2
option_random_chaos = 3
default = 0

View File

@@ -67,10 +67,10 @@ def create_regions(self):
self.multiworld.regions.append(create_region(self.multiworld, self.player, room["name"], room["id"],
[FFMQLocation(self.player, object["name"], location_table[object["name"]] if object["name"] in
location_table else None, object["type"], object["access"],
self.create_item(yaml_item(object["on_trigger"][0])) if object["type"] == "Trigger" else None) for
object in room["game_objects"] if "Hero Chest" not in object["name"] and object["type"] not in
("BattlefieldGp", "BattlefieldXp") and (object["type"] != "Box" or
self.multiworld.brown_boxes[self.player] == "include")], room["links"]))
self.create_item(yaml_item(object["on_trigger"][0])) if object["type"] == "Trigger" else None) for object in
room["game_objects"] if "Hero Chest" not in object["name"] and object["type"] not in ("BattlefieldGp",
"BattlefieldXp") and (object["type"] != "Box" or self.multiworld.brown_boxes[self.player] == "include") and
not (object["name"] == "Kaeli Companion" and not object["on_trigger"])], room["links"]))
dark_king_room = self.multiworld.get_region("Doom Castle Dark King Room", self.player)
dark_king = FFMQLocation(self.player, "Dark King", None, "Trigger", [])

View File

@@ -85,7 +85,7 @@ Final Fantasy Mystic Quest:
Normal: 0
OneAndHalf: 0
Double: 0
DoubleHalf: 0
DoubleAndHalf: 0
Triple: 0
Quadruple: 0
companion_leveling_type:
@@ -98,7 +98,7 @@ Final Fantasy Mystic Quest:
BenPlus10: 0
companion_spellbook_type:
Standard: 0
StandardExtended: 0
Extended: 0
RandomBalanced: 0
RandomChaos: 0
starting_companion:

View File

@@ -419,17 +419,16 @@ class HKWorld(World):
def set_rules(self):
world = self.multiworld
player = self.player
if world.logic[player] != 'nologic':
goal = world.Goal[player]
if goal == Goal.option_hollowknight:
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player)
elif goal == Goal.option_siblings:
world.completion_condition[player] = lambda state: state._hk_siblings_ending(player)
elif goal == Goal.option_radiance:
world.completion_condition[player] = lambda state: state._hk_can_beat_radiance(player)
else:
# Any goal
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player) or state._hk_can_beat_radiance(player)
goal = world.Goal[player]
if goal == Goal.option_hollowknight:
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player)
elif goal == Goal.option_siblings:
world.completion_condition[player] = lambda state: state._hk_siblings_ending(player)
elif goal == Goal.option_radiance:
world.completion_condition[player] = lambda state: state._hk_can_beat_radiance(player)
else:
# Any goal
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player) or state._hk_can_beat_radiance(player)
set_rules(self)

View File

@@ -29,6 +29,7 @@ class KH2Context(CommonContext):
self.kh2_local_items = None
self.growthlevel = None
self.kh2connected = False
self.kh2_finished_game = False
self.serverconneced = False
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
@@ -833,9 +834,9 @@ async def kh2_watcher(ctx: KH2Context):
await asyncio.create_task(ctx.verifyItems())
await asyncio.create_task(ctx.verifyLevel())
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
if finishedGame(ctx, message):
if finishedGame(ctx, message) and not ctx.kh2_finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
ctx.kh2_finished_game = True
await ctx.send_msgs(message)
elif not ctx.kh2connected and ctx.serverconneced:
logger.info("Game Connection lost. waiting 15 seconds until trying to reconnect.")

View File

@@ -1020,10 +1020,9 @@ def create_regions(self):
multiworld.regions += [create_region(multiworld, player, active_locations, region, locations) for region, locations in
KH2REGIONS.items()]
# fill the event locations with events
multiworld.worlds[player].item_name_to_id.update({event_name: None for event_name in Events_Table})
for location, item in event_location_to_item.items():
multiworld.get_location(location, player).place_locked_item(
multiworld.worlds[player].create_item(item))
multiworld.worlds[player].create_event_item(item))
def connect_regions(self):

View File

@@ -224,7 +224,7 @@ class KH2WorldRules(KH2Rules):
RegionName.Pl2: lambda state: self.pl_unlocked(state, 2),
RegionName.Ag: lambda state: self.ag_unlocked(state, 1),
RegionName.Ag2: lambda state: self.ag_unlocked(state, 2),
RegionName.Ag2: lambda state: self.ag_unlocked(state, 2) and self.kh2_has_all([ItemName.FireElement,ItemName.BlizzardElement,ItemName.ThunderElement],state),
RegionName.Bc: lambda state: self.bc_unlocked(state, 1),
RegionName.Bc2: lambda state: self.bc_unlocked(state, 2),

View File

@@ -119,11 +119,15 @@ class KH2World(World):
item_classification = ItemClassification.useful
else:
item_classification = ItemClassification.filler
created_item = KH2Item(name, item_classification, self.item_name_to_id[name], self.player)
return created_item
def create_event_item(self, name: str) -> Item:
item_classification = ItemClassification.progression
created_item = KH2Item(name, item_classification, None, self.player)
return created_item
def create_items(self) -> None:
"""
Fills ItemPool and manages schmovement, random growth, visit locking and random starting visit locking.
@@ -461,7 +465,7 @@ class KH2World(World):
if location in self.random_super_boss_list:
self.random_super_boss_list.remove(location)
if not self.options.SummonLevelLocationToggle:
if not self.options.SummonLevelLocationToggle and LocationName.Summonlvl7 in self.random_super_boss_list:
self.random_super_boss_list.remove(LocationName.Summonlvl7)
# Testing if the player has the right amount of Bounties for Completion.

View File

@@ -104,9 +104,11 @@ class LingoWorld(World):
classification = item.classification
if hasattr(self, "options") and self.options.shuffle_paintings and len(item.painting_ids) > 0\
and len(item.door_ids) == 0 and all(painting_id not in self.player_logic.painting_mapping
for painting_id in item.painting_ids):
for painting_id in item.painting_ids)\
and "pilgrim_painting2" not in item.painting_ids:
# If this is a "door" that just moves one or more paintings, and painting shuffle is on and those paintings
# go nowhere, then this item should not be progression.
# go nowhere, then this item should not be progression. The Pilgrim Room painting is special and needs to be
# excluded from this.
classification = ItemClassification.filler
return LingoItem(name, classification, item.code, self.player)

View File

@@ -373,6 +373,7 @@
ANOTHER TRY:
id: Entry Room/Panel_advance
tag: topwhite
non_counting: True # This is a counting panel in-game, but it can never count towards the LEVEL 2 panel hunt.
LEVEL 2:
# We will set up special rules for this in code.
id: EndPanel/Panel_level_2
@@ -1033,6 +1034,8 @@
Hallway Room (3): True
Hallway Room (4): True
Hedge Maze: True # through the door to the sectioned-off part of the hedge maze
Cellar:
door: Lookout Entrance
panels:
MASSACRED:
id: Palindrome Room/Panel_massacred_sacred
@@ -1168,11 +1171,21 @@
- KEEP
- BAILEY
- TOWER
Lookout Entrance:
id: Cross Room Doors/Door_missing
location_name: Outside The Agreeable - Lookout Panels
panels:
- NORTH
- WINTER
- DIAMONDS
- FIRE
paintings:
- id: panda_painting
orientation: south
- id: eyes_yellow_painting
orientation: east
- id: pencil_painting7
orientation: north
progression:
Progressive Hallway Room:
- Hallway Door
@@ -2043,7 +2056,7 @@
door: Sixth Floor
Cellar:
room: Room Room
door: Shortcut to Fifth Floor
door: Cellar Exit
Welcome Back Area:
door: Welcome Back
Art Gallery:
@@ -2302,9 +2315,6 @@
id: Master Room/Panel_mastery_mastery3
tag: midwhite
hunt: True
required_door:
room: Orange Tower Seventh Floor
door: Mastery
THE LIBRARY:
id: EndPanel/Panel_library
check: True
@@ -2675,6 +2685,10 @@
Outside The Undeterred: True
Outside The Agreeable: True
Outside The Wanderer: True
The Observant: True
Art Gallery: True
The Scientific: True
Cellar: True
Orange Tower Fifth Floor:
room: Orange Tower Fifth Floor
door: Welcome Back
@@ -2991,8 +3005,7 @@
PATS:
id: Rhyme Room/Panel_wrath_path
colors: purple
tag: midpurp and rhyme
copy_to_sign: sign15
tag: forbid
KNIGHT:
id: Rhyme Room/Panel_knight_write
colors: purple
@@ -3158,6 +3171,8 @@
door: Painting Shortcut
painting: True
Room Room: True # trapdoor
Outside The Agreeable:
painting: True
panels:
UNOPEN:
id: Truncate Room/Panel_unopened_open
@@ -6299,17 +6314,22 @@
SKELETON:
id: Double Room/Panel_bones_syn
tag: syn rhyme
colors: purple
subtag: bot
link: rhyme BONES
REPENTANCE:
id: Double Room/Panel_sentence_rhyme
colors: purple
colors:
- purple
- blue
tag: whole rhyme
subtag: top
link: rhyme SENTENCE
WORD:
id: Double Room/Panel_sentence_whole
colors: blue
colors:
- purple
- blue
tag: whole rhyme
subtag: bot
link: rhyme SENTENCE
@@ -6321,6 +6341,7 @@
link: rhyme DREAM
FANTASY:
id: Double Room/Panel_dream_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme DREAM
@@ -6332,6 +6353,7 @@
link: rhyme MYSTERY
SECRET:
id: Double Room/Panel_mystery_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme MYSTERY
@@ -6386,25 +6408,33 @@
door: Nines
FERN:
id: Double Room/Panel_return_rhyme
colors: purple
colors:
- purple
- black
tag: ant rhyme
subtag: top
link: rhyme RETURN
STAY:
id: Double Room/Panel_return_ant
colors: black
colors:
- purple
- black
tag: ant rhyme
subtag: bot
link: rhyme RETURN
FRIEND:
id: Double Room/Panel_descend_rhyme
colors: purple
colors:
- purple
- black
tag: ant rhyme
subtag: top
link: rhyme DESCEND
RISE:
id: Double Room/Panel_descend_ant
colors: black
colors:
- purple
- black
tag: ant rhyme
subtag: bot
link: rhyme DESCEND
@@ -6416,6 +6446,7 @@
link: rhyme JUMP
BOUNCE:
id: Double Room/Panel_jump_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme JUMP
@@ -6427,6 +6458,7 @@
link: rhyme FALL
PLUNGE:
id: Double Room/Panel_fall_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme FALL
@@ -6456,13 +6488,17 @@
panels:
BIRD:
id: Double Room/Panel_word_rhyme
colors: purple
colors:
- purple
- blue
tag: whole rhyme
subtag: top
link: rhyme WORD
LETTER:
id: Double Room/Panel_word_whole
colors: blue
colors:
- purple
- blue
tag: whole rhyme
subtag: bot
link: rhyme WORD
@@ -6474,6 +6510,7 @@
link: rhyme HIDDEN
CONCEALED:
id: Double Room/Panel_hidden_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme HIDDEN
@@ -6485,6 +6522,7 @@
link: rhyme SILENT
MUTE:
id: Double Room/Panel_silent_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme SILENT
@@ -6531,6 +6569,7 @@
link: rhyme BLOCKED
OBSTRUCTED:
id: Double Room/Panel_blocked_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme BLOCKED
@@ -6542,6 +6581,7 @@
link: rhyme RISE
SWELL:
id: Double Room/Panel_rise_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme RISE
@@ -6553,6 +6593,7 @@
link: rhyme ASCEND
CLIMB:
id: Double Room/Panel_ascend_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme ASCEND
@@ -6564,6 +6605,7 @@
link: rhyme DOUBLE
DUPLICATE:
id: Double Room/Panel_double_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme DOUBLE
@@ -6642,6 +6684,7 @@
link: rhyme CHILD
KID:
id: Double Room/Panel_child_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme CHILD
@@ -6653,6 +6696,7 @@
link: rhyme CRYSTAL
QUARTZ:
id: Double Room/Panel_crystal_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme CRYSTAL
@@ -6664,6 +6708,7 @@
link: rhyme CREATIVE
INNOVATIVE (Bottom):
id: Double Room/Panel_creative_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme CREATIVE
@@ -6882,7 +6927,7 @@
event: True
panels:
- WALL (1)
Shortcut to Fifth Floor:
Cellar Exit:
id:
- Tower Room Area Doors/Door_panel_basement
- Tower Room Area Doors/Door_panel_basement2
@@ -6895,7 +6940,10 @@
door: Excavation
Orange Tower Fifth Floor:
room: Room Room
door: Shortcut to Fifth Floor
door: Cellar Exit
Outside The Agreeable:
room: Outside The Agreeable
door: Lookout Entrance
Outside The Wise:
entrances:
Orange Tower Sixth Floor:
@@ -7319,49 +7367,65 @@
link: change GRAVITY
PART:
id: Chemistry Room/Panel_physics_2
colors: blue
colors:
- blue
- red
tag: blue mid red bot
subtag: mid
link: xur PARTICLE
MATTER:
id: Chemistry Room/Panel_physics_1
colors: red
colors:
- blue
- red
tag: blue mid red bot
subtag: bot
link: xur PARTICLE
ELECTRIC:
id: Chemistry Room/Panel_physics_6
colors: purple
colors:
- purple
- red
tag: purple mid red bot
subtag: mid
link: xpr ELECTRON
ATOM (1):
id: Chemistry Room/Panel_physics_3
colors: red
colors:
- purple
- red
tag: purple mid red bot
subtag: bot
link: xpr ELECTRON
NEUTRAL:
id: Chemistry Room/Panel_physics_7
colors: purple
colors:
- purple
- red
tag: purple mid red bot
subtag: mid
link: xpr NEUTRON
ATOM (2):
id: Chemistry Room/Panel_physics_4
colors: red
colors:
- purple
- red
tag: purple mid red bot
subtag: bot
link: xpr NEUTRON
PROPEL:
id: Chemistry Room/Panel_physics_8
colors: purple
colors:
- purple
- red
tag: purple mid red bot
subtag: mid
link: xpr PROTON
ATOM (3):
id: Chemistry Room/Panel_physics_5
colors: red
colors:
- purple
- red
tag: purple mid red bot
subtag: bot
link: xpr PROTON

View File

@@ -1064,6 +1064,9 @@ doors:
Hallway Door:
item: 444459
location: 445214
Lookout Entrance:
item: 444579
location: 445271
Dread Hallway:
Tenacious Entrance:
item: 444462
@@ -1402,7 +1405,7 @@ doors:
item: 444570
location: 445266
Room Room:
Shortcut to Fifth Floor:
Cellar Exit:
item: 444571
location: 445076
Outside The Wise:

View File

@@ -190,6 +190,25 @@ class LingoPlayerLogic:
if item.should_include(world):
self.real_items.append(name)
# Calculate the requirements for the fake pilgrimage.
fake_pilgrimage = [
["Second Room", "Exit Door"], ["Crossroads", "Tower Entrance"],
["Orange Tower Fourth Floor", "Hot Crusts Door"], ["Outside The Initiated", "Shortcut to Hub Room"],
["Orange Tower First Floor", "Shortcut to Hub Room"], ["Directional Gallery", "Shortcut to The Undeterred"],
["Orange Tower First Floor", "Salt Pepper Door"], ["Hub Room", "Crossroads Entrance"],
["Champion's Rest", "Shortcut to The Steady"], ["The Bearer", "Shortcut to The Bold"],
["Art Gallery", "Exit"], ["The Tenacious", "Shortcut to Hub Room"],
["Outside The Agreeable", "Tenacious Entrance"]
]
pilgrimage_reqs = AccessRequirements()
for door in fake_pilgrimage:
door_object = DOORS_BY_ROOM[door[0]][door[1]]
if door_object.event or world.options.shuffle_doors == ShuffleDoors.option_none:
pilgrimage_reqs.merge(self.calculate_door_requirements(door[0], door[1], world))
else:
pilgrimage_reqs.doors.add(RoomAndDoor(door[0], door[1]))
self.door_reqs.setdefault("Pilgrim Antechamber", {})["Pilgrimage"] = pilgrimage_reqs
# Create the paintings mapping, if painting shuffle is on.
if painting_shuffle:
# Shuffle paintings until we get something workable.
@@ -369,11 +388,9 @@ class LingoPlayerLogic:
door_object = DOORS_BY_ROOM[room][door]
for req_panel in door_object.panels:
if req_panel.room is not None and req_panel.room != room:
access_reqs.rooms.add(req_panel.room)
sub_access_reqs = self.calculate_panel_requirements(room if req_panel.room is None else req_panel.room,
req_panel.panel, world)
panel_room = room if req_panel.room is None else req_panel.room
access_reqs.rooms.add(panel_room)
sub_access_reqs = self.calculate_panel_requirements(panel_room, req_panel.panel, world)
access_reqs.merge(sub_access_reqs)
self.door_reqs[room][door] = access_reqs
@@ -397,8 +414,8 @@ class LingoPlayerLogic:
unhindered_panels_by_color: dict[Optional[str], int] = {}
for panel_name, panel_data in room_data.items():
# We won't count non-counting panels.
if panel_data.non_counting:
# We won't count non-counting panels. THE MASTER has special access rules and is handled separately.
if panel_data.non_counting or panel_name == "THE MASTER":
continue
# We won't coalesce any panels that have requirements beyond colors. To simplify things for now, we will

View File

@@ -4,7 +4,7 @@ from BaseClasses import Entrance, ItemClassification, Region
from .items import LingoItem
from .locations import LingoLocation
from .player_logic import LingoPlayerLogic
from .rules import lingo_can_use_entrance, lingo_can_use_pilgrimage, make_location_lambda
from .rules import lingo_can_use_entrance, make_location_lambda
from .static_logic import ALL_ROOMS, PAINTINGS, Room, RoomAndDoor
if TYPE_CHECKING:
@@ -25,15 +25,6 @@ def create_region(room: Room, world: "LingoWorld", player_logic: LingoPlayerLogi
return new_region
def handle_pilgrim_room(regions: Dict[str, Region], world: "LingoWorld", player_logic: LingoPlayerLogic) -> None:
target_region = regions["Pilgrim Antechamber"]
source_region = regions["Outside The Agreeable"]
source_region.connect(
target_region,
"Pilgrimage",
lambda state: lingo_can_use_pilgrimage(state, world, player_logic))
def connect_entrance(regions: Dict[str, Region], source_region: Region, target_region: Region, description: str,
door: Optional[RoomAndDoor], world: "LingoWorld", player_logic: LingoPlayerLogic):
connection = Entrance(world.player, description, source_region)
@@ -91,7 +82,9 @@ def create_regions(world: "LingoWorld", player_logic: LingoPlayerLogic) -> None:
connect_entrance(regions, regions[entrance.room], regions[room.name], entrance_name, entrance.door, world,
player_logic)
handle_pilgrim_room(regions, world, player_logic)
# Add the fake pilgrimage.
connect_entrance(regions, regions["Outside The Agreeable"], regions["Pilgrim Antechamber"], "Pilgrimage",
RoomAndDoor("Pilgrim Antechamber", "Pilgrimage"), world, player_logic)
if early_color_hallways:
regions["Starting Room"].connect(regions["Outside The Undeterred"], "Early Color Hallways")

View File

@@ -17,23 +17,6 @@ def lingo_can_use_entrance(state: CollectionState, room: str, door: RoomAndDoor,
return _lingo_can_open_door(state, effective_room, door.door, world, player_logic)
def lingo_can_use_pilgrimage(state: CollectionState, world: "LingoWorld", player_logic: LingoPlayerLogic):
fake_pilgrimage = [
["Second Room", "Exit Door"], ["Crossroads", "Tower Entrance"],
["Orange Tower Fourth Floor", "Hot Crusts Door"], ["Outside The Initiated", "Shortcut to Hub Room"],
["Orange Tower First Floor", "Shortcut to Hub Room"], ["Directional Gallery", "Shortcut to The Undeterred"],
["Orange Tower First Floor", "Salt Pepper Door"], ["Hub Room", "Crossroads Entrance"],
["Champion's Rest", "Shortcut to The Steady"], ["The Bearer", "Shortcut to The Bold"],
["Art Gallery", "Exit"], ["The Tenacious", "Shortcut to Hub Room"],
["Outside The Agreeable", "Tenacious Entrance"]
]
for entrance in fake_pilgrimage:
if not _lingo_can_open_door(state, entrance[0], entrance[1], world, player_logic):
return False
return True
def lingo_can_use_location(state: CollectionState, location: PlayerLocation, world: "LingoWorld",
player_logic: LingoPlayerLogic):
return _lingo_can_satisfy_requirements(state, location.access, world, player_logic)
@@ -56,6 +39,12 @@ def lingo_can_use_level_2_location(state: CollectionState, world: "LingoWorld",
counted_panels += panel_count
if counted_panels >= world.options.level_2_requirement.value - 1:
return True
# THE MASTER has to be handled separately, because it has special access rules.
if state.can_reach("Orange Tower Seventh Floor", "Region", world.player)\
and lingo_can_use_mastery_location(state, world, player_logic):
counted_panels += 1
if counted_panels >= world.options.level_2_requirement.value - 1:
return True
return False

View File

@@ -40,7 +40,7 @@ mentioned_panels = Set[]
door_groups = {}
directives = Set["entrances", "panels", "doors", "paintings", "progression"]
panel_directives = Set["id", "required_room", "required_door", "required_panel", "colors", "check", "exclude_reduce", "tag", "link", "subtag", "achievement", "copy_to_sign", "non_counting"]
panel_directives = Set["id", "required_room", "required_door", "required_panel", "colors", "check", "exclude_reduce", "tag", "link", "subtag", "achievement", "copy_to_sign", "non_counting", "hunt"]
door_directives = Set["id", "painting_id", "panels", "item_name", "location_name", "skip_location", "skip_item", "group", "include_reduce", "junk_item", "event"]
painting_directives = Set["id", "enter_only", "exit_only", "orientation", "required_door", "required", "required_when_no_doors", "move", "req_blocked", "req_blocked_when_no_doors"]

View File

@@ -6,7 +6,7 @@ from NetUtils import ClientStatus
from worlds._bizhawk.client import BizHawkClient
from worlds._bizhawk import read, write, guarded_write
from worlds.pokemon_rb.locations import location_data
from .locations import location_data
logger = logging.getLogger("Client")

View File

@@ -168,12 +168,12 @@ rom_addresses = {
"Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_0_ITEM": 0x1a61c,
"Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_1_ITEM": 0x1a62a,
"Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_2_ITEM": 0x1a638,
"Event_SKC6F": 0x1a666,
"Warps_SilphCo6F": 0x1a741,
"Missable_Silph_Co_6F_Item_1": 0x1a791,
"Missable_Silph_Co_6F_Item_2": 0x1a798,
"Path_Pallet_Oak": 0x1a91e,
"Path_Pallet_Player": 0x1a92b,
"Event_SKC6F": 0x1a659,
"Warps_SilphCo6F": 0x1a737,
"Missable_Silph_Co_6F_Item_1": 0x1a787,
"Missable_Silph_Co_6F_Item_2": 0x1a78e,
"Path_Pallet_Oak": 0x1a914,
"Path_Pallet_Player": 0x1a921,
"Warps_CinnabarIsland": 0x1c026,
"Warps_Route1": 0x1c0e9,
"Option_Extra_Key_Items_B": 0x1ca46,

View File

@@ -103,12 +103,10 @@ class RiskOfRainWorld(World):
if self.options.dlc_sotv:
environment_offset_table = shift_by_offset(environment_sotv_table, environment_offset)
environments_pool = {**environments_pool, **environment_offset_table}
environments_to_precollect = 5 if self.options.begin_with_loop else 1
# percollect environments for each stage (or just stage 1)
for i in range(environments_to_precollect):
unlock = self.random.choices(list(environment_available_orderedstages_table[i].keys()), k=1)
self.multiworld.push_precollected(self.create_item(unlock[0]))
environments_pool.pop(unlock[0])
# 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]))
environments_pool.pop(unlock[0])
# Generate item pool
itempool: List[str] = ["Beads of Fealty", "Radar Scanner"]

View File

@@ -142,14 +142,6 @@ class FinalStageDeath(Toggle):
display_name = "Final Stage Death is Win"
class BeginWithLoop(Toggle):
"""
Enable to precollect a full loop of environments.
Only has an effect with Explore Mode.
"""
display_name = "Begin With Loop"
class DLC_SOTV(Toggle):
"""
Enable if you are using SOTV DLC.
@@ -385,7 +377,6 @@ class ROR2Options(PerGameCommonOptions):
total_revivals: TotalRevivals
start_with_revive: StartWithRevive
final_stage_death: FinalStageDeath
begin_with_loop: BeginWithLoop
dlc_sotv: DLC_SOTV
death_link: DeathLink
item_pickup_step: ItemPickupStep

View File

@@ -638,7 +638,7 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla
add_rule(multiworld.get_location(LocationName.radical_highway_omo_2, player),
lambda state: state.has(ItemName.shadow_air_shoes, player))
add_rule(multiworld.get_location(LocationName.weapons_bed_omo_2, player),
lambda state: state.has(ItemName.eggman_jet_engine, player))
lambda state: state.has(ItemName.eggman_large_cannon, player))
add_rule(multiworld.get_location(LocationName.mission_street_omo_3, player),
lambda state: state.has(ItemName.tails_booster, player))

View File

@@ -170,7 +170,8 @@ class GraphBuilder(object):
ap = "Landing Site" # dummy value it'll be overwritten at first collection
while len(itemLocs) > 0 and not (sm.canPassG4() and graph.canAccess(sm, ap, "Landing Site", maxDiff)):
il = itemLocs.pop(0)
if il.Location.restricted or il.Item.Type == "ArchipelagoItem":
# can happen with item links replacement items that its not in the container's itemPool
if il.Location.restricted or il.Item.Type == "ArchipelagoItem" or il.Item not in container.itemPool:
continue
self.log.debug("collecting " + getItemLocStr(il))
container.collect(il)

View File

@@ -1,4 +1,7 @@
import typing
from enum import Enum
from BaseClasses import MultiWorld, Region, Entrance, Location
from .Locations import SM64Location, location_table, locBoB_table, locWhomp_table, locJRB_table, locCCM_table, \
locBBH_table, \
@@ -7,36 +10,63 @@ from .Locations import SM64Location, location_table, locBoB_table, locWhomp_tabl
locPSS_table, locSA_table, locBitDW_table, locTotWC_table, locCotMC_table, \
locVCutM_table, locBitFS_table, locWMotR_table, locBitS_table, locSS_table
# sm64paintings is dict of entrances, format LEVEL | AREA
sm64_level_to_paintings = {
91: "Bob-omb Battlefield",
241: "Whomp's Fortress",
121: "Jolly Roger Bay",
51: "Cool, Cool Mountain",
41: "Big Boo's Haunt",
71: "Hazy Maze Cave",
221: "Lethal Lava Land",
81: "Shifting Sand Land",
231: "Dire, Dire Docks",
101: "Snowman's Land",
111: "Wet-Dry World",
361: "Tall, Tall Mountain",
132: "Tiny-Huge Island (Tiny)",
131: "Tiny-Huge Island (Huge)",
141: "Tick Tock Clock",
151: "Rainbow Ride"
class SM64Levels(int, Enum):
BOB_OMB_BATTLEFIELD = 91
WHOMPS_FORTRESS = 241
JOLLY_ROGER_BAY = 121
COOL_COOL_MOUNTAIN = 51
BIG_BOOS_HAUNT = 41
HAZY_MAZE_CAVE = 71
LETHAL_LAVA_LAND = 221
SHIFTING_SAND_LAND = 81
DIRE_DIRE_DOCKS = 231
SNOWMANS_LAND = 101
WET_DRY_WORLD = 111
TALL_TALL_MOUNTAIN = 361
TINY_HUGE_ISLAND_TINY = 132
TINY_HUGE_ISLAND_HUGE = 131
TICK_TOCK_CLOCK = 141
RAINBOW_RIDE = 151
THE_PRINCESS_SECRET_SLIDE = 271
THE_SECRET_AQUARIUM = 201
BOWSER_IN_THE_DARK_WORLD = 171
TOWER_OF_THE_WING_CAP = 291
CAVERN_OF_THE_METAL_CAP = 281
VANISH_CAP_UNDER_THE_MOAT = 181
BOWSER_IN_THE_FIRE_SEA = 191
WING_MARIO_OVER_THE_RAINBOW = 311
# sm64paintings is a dict of entrances, format LEVEL | AREA
sm64_level_to_paintings: typing.Dict[SM64Levels, str] = {
SM64Levels.BOB_OMB_BATTLEFIELD: "Bob-omb Battlefield",
SM64Levels.WHOMPS_FORTRESS: "Whomp's Fortress",
SM64Levels.JOLLY_ROGER_BAY: "Jolly Roger Bay",
SM64Levels.COOL_COOL_MOUNTAIN: "Cool, Cool Mountain",
SM64Levels.BIG_BOOS_HAUNT: "Big Boo's Haunt",
SM64Levels.HAZY_MAZE_CAVE: "Hazy Maze Cave",
SM64Levels.LETHAL_LAVA_LAND: "Lethal Lava Land",
SM64Levels.SHIFTING_SAND_LAND: "Shifting Sand Land",
SM64Levels.DIRE_DIRE_DOCKS: "Dire, Dire Docks",
SM64Levels.SNOWMANS_LAND: "Snowman's Land",
SM64Levels.WET_DRY_WORLD: "Wet-Dry World",
SM64Levels.TALL_TALL_MOUNTAIN: "Tall, Tall Mountain",
SM64Levels.TINY_HUGE_ISLAND_TINY: "Tiny-Huge Island (Tiny)",
SM64Levels.TINY_HUGE_ISLAND_HUGE: "Tiny-Huge Island (Huge)",
SM64Levels.TICK_TOCK_CLOCK: "Tick Tock Clock",
SM64Levels.RAINBOW_RIDE: "Rainbow Ride"
}
sm64_paintings_to_level = { painting: level for (level,painting) in sm64_level_to_paintings.items() }
# sm64secrets is list of secret areas, same format
sm64_level_to_secrets = {
271: "The Princess's Secret Slide",
201: "The Secret Aquarium",
171: "Bowser in the Dark World",
291: "Tower of the Wing Cap",
281: "Cavern of the Metal Cap",
181: "Vanish Cap under the Moat",
191: "Bowser in the Fire Sea",
311: "Wing Mario over the Rainbow"
# sm64secrets is a dict of secret areas, same format as sm64paintings
sm64_level_to_secrets: typing.Dict[SM64Levels, str] = {
SM64Levels.THE_PRINCESS_SECRET_SLIDE: "The Princess's Secret Slide",
SM64Levels.THE_SECRET_AQUARIUM: "The Secret Aquarium",
SM64Levels.BOWSER_IN_THE_DARK_WORLD: "Bowser in the Dark World",
SM64Levels.TOWER_OF_THE_WING_CAP: "Tower of the Wing Cap",
SM64Levels.CAVERN_OF_THE_METAL_CAP: "Cavern of the Metal Cap",
SM64Levels.VANISH_CAP_UNDER_THE_MOAT: "Vanish Cap under the Moat",
SM64Levels.BOWSER_IN_THE_FIRE_SEA: "Bowser in the Fire Sea",
SM64Levels.WING_MARIO_OVER_THE_RAINBOW: "Wing Mario over the Rainbow"
}
sm64_secrets_to_level = { secret: level for (level,secret) in sm64_level_to_secrets.items() }

View File

@@ -1,5 +1,5 @@
from ..generic.Rules import add_rule
from .Regions import connect_regions, sm64_level_to_paintings, sm64_paintings_to_level, sm64_level_to_secrets, sm64_entrances_to_level, sm64_level_to_entrances
from .Regions import connect_regions, SM64Levels, sm64_level_to_paintings, sm64_paintings_to_level, sm64_level_to_secrets, sm64_secrets_to_level, sm64_entrances_to_level, sm64_level_to_entrances
def shuffle_dict_keys(world, obj: dict) -> dict:
keys = list(obj.keys())
@@ -7,12 +7,16 @@ def shuffle_dict_keys(world, obj: dict) -> dict:
world.random.shuffle(keys)
return dict(zip(keys,values))
def fix_reg(entrance_ids, entrance, destination, swapdict, world):
if entrance_ids[entrance] == destination: # Unlucky :C
rand = world.random.choice(swapdict.keys())
entrance_ids[entrance], entrance_ids[swapdict[rand]] = rand, entrance_ids[entrance]
swapdict[rand] = entrance_ids[entrance]
swapdict.pop(entrance)
def fix_reg(entrance_map: dict, entrance: SM64Levels, invalid_regions: set,
swapdict: dict, world):
if entrance_map[entrance] in invalid_regions: # Unlucky :C
replacement_regions = [(rand_region, rand_entrance) for rand_region, rand_entrance in swapdict.items()
if rand_region not in invalid_regions]
rand_region, rand_entrance = world.random.choice(replacement_regions)
old_dest = entrance_map[entrance]
entrance_map[entrance], entrance_map[rand_entrance] = rand_region, old_dest
swapdict[rand_region] = entrance
swapdict.pop(entrance_map[entrance]) # Entrance now fixed to rand_region
def set_rules(world, player: int, area_connections: dict):
randomized_level_to_paintings = sm64_level_to_paintings.copy()
@@ -24,22 +28,20 @@ def set_rules(world, player: int, area_connections: dict):
randomized_entrances = { **randomized_level_to_paintings, **randomized_level_to_secrets }
if world.AreaRandomizer[player].value == 3: # Randomize Courses and Secrets in one pool
randomized_entrances = shuffle_dict_keys(world,randomized_entrances)
swapdict = { entrance: level for (level,entrance) in randomized_entrances.items() }
# Guarantee first entrance is a course
swapdict = { entrance: level for (level,entrance) in randomized_entrances }
if randomized_entrances[91] not in sm64_paintings_to_level.keys(): # Unlucky :C (91 -> BoB Entrance)
rand = world.random.choice(sm64_paintings_to_level.values())
randomized_entrances[91], randomized_entrances[swapdict[rand]] = rand, randomized_entrances[91]
swapdict[rand] = randomized_entrances[91]
swapdict.pop("Bob-omb Battlefield")
# Guarantee COTMC is not mapped to HMC, cuz thats impossible
fix_reg(randomized_entrances, "Cavern of the Metal Cap", "Hazy Maze Cave", swapdict, world)
fix_reg(randomized_entrances, SM64Levels.BOB_OMB_BATTLEFIELD, sm64_secrets_to_level.keys(), swapdict, world)
# Guarantee BITFS is not mapped to DDD
fix_reg(randomized_entrances, "Bowser in the Fire Sea", "Dire, Dire Docks", swapdict, world)
if randomized_entrances[191] == "Hazy Maze Cave": # If BITFS is mapped to HMC...
fix_reg(randomized_entrances, "Cavern of the Metal Cap", "Dire, Dire Docks", swapdict, world) # ... then dont allow COTMC to be mapped to DDD
fix_reg(randomized_entrances, SM64Levels.BOWSER_IN_THE_FIRE_SEA, {"Dire, Dire Docks"}, swapdict, world)
# Guarantee COTMC is not mapped to HMC, cuz thats impossible. If BitFS -> HMC, also no COTMC -> DDD.
if randomized_entrances[SM64Levels.BOWSER_IN_THE_FIRE_SEA] == "Hazy Maze Cave":
fix_reg(randomized_entrances, SM64Levels.CAVERN_OF_THE_METAL_CAP, {"Hazy Maze Cave", "Dire, Dire Docks"}, swapdict, world)
else:
fix_reg(randomized_entrances, SM64Levels.CAVERN_OF_THE_METAL_CAP, {"Hazy Maze Cave"}, swapdict, world)
# Destination Format: LVL | AREA with LVL = LEVEL_x, AREA = Area as used in sm64 code
area_connections.update({entrance_lvl: sm64_entrances_to_level[destination] for (entrance_lvl,destination) in randomized_entrances.items()})
# Cast to int to not rely on availability of SM64Levels enum. Will cause crash in MultiServer otherwise
area_connections.update({int(entrance_lvl): int(sm64_entrances_to_level[destination]) for (entrance_lvl,destination) in randomized_entrances.items()})
randomized_entrances_s = {sm64_level_to_entrances[entrance_lvl]: destination for (entrance_lvl,destination) in randomized_entrances.items()}
connect_regions(world, player, "Menu", randomized_entrances_s["Bob-omb Battlefield"])

View File

@@ -319,7 +319,7 @@ class Patch:
def WriteZ3Locations(self, locations: List[Location]):
for location in locations:
if (location.Type == LocationType.HeraStandingKey):
self.patches.append((Snes(0x9E3BB), [0xE4] if location.APLocation.item.game == "SMZ3" and location.APLocation.item.item.Type == ItemType.KeyTH else [0xEB]))
self.patches.append((Snes(0x9E3BB), [0xEB]))
elif (location.Type in [LocationType.Pedestal, LocationType.Ether, LocationType.Bombos]):
text = Texts.ItemTextbox(location.APLocation.item.item if location.APLocation.item.game == "SMZ3" else Item(ItemType.Something))
if (location.Type == LocationType.Pedestal):

View File

@@ -117,6 +117,9 @@ def get_pool_core(world):
else:
possible_level_locations = [location for location in standard_level_locations
if location not in level_locations[8]]
for location in placed_items.keys():
if location in possible_level_locations:
possible_level_locations.remove(location)
for level in range(1, 9):
if world.multiworld.TriforceLocations[world.player] == TriforceLocations.option_vanilla:
placed_items[f"Level {level} Triforce"] = fragment

View File

@@ -430,7 +430,7 @@ Door - 0x04F8F (Tower Shortcut) - 0x0361B
158705 - 0x03317 (Laser Panel Pressure Plates) - 0x033EA & 0x01BE9 & 0x01CD3 & 0x01D3F - Dots & Shapers & Black/White Squares & Rotated Shapers
Laser - 0x014BB (Laser) - 0x0360E | 0x03317
159240 - 0x033BE (Pressure Plates 1 EP) - 0x033EA - True
159241 - 0x033BF (Pressure Plates 2 EP) - 0x01BE9 - True
159241 - 0x033BF (Pressure Plates 2 EP) - 0x01BE9 & 0x01BEA - True
159242 - 0x033DD (Pressure Plates 3 EP) - 0x01CD3 & 0x01CD5 - True
159243 - 0x033E5 (Pressure Plates 4 Left Exit EP) - 0x01D3F - True
159244 - 0x018B6 (Pressure Plates 4 Right Exit EP) - 0x01D3F - True

View File

@@ -161,7 +161,7 @@ joke_hints = [
]
def get_always_hint_items(world: "WitnessWorld"):
def get_always_hint_items(world: "WitnessWorld") -> List[str]:
always = [
"Boat",
"Caves Shortcuts",
@@ -187,17 +187,17 @@ def get_always_hint_items(world: "WitnessWorld"):
return always
def get_always_hint_locations(_: "WitnessWorld"):
return {
def get_always_hint_locations(_: "WitnessWorld") -> List[str]:
return [
"Challenge Vault Box",
"Mountain Bottom Floor Discard",
"Theater Eclipse EP",
"Shipwreck Couch EP",
"Mountainside Cloud Cycle EP",
}
]
def get_priority_hint_items(world: "WitnessWorld"):
def get_priority_hint_items(world: "WitnessWorld") -> List[str]:
priority = {
"Caves Mountain Shortcut (Door)",
"Caves Swamp Shortcut (Door)",
@@ -246,11 +246,11 @@ def get_priority_hint_items(world: "WitnessWorld"):
lasers.append("Desert Laser")
priority.update(world.random.sample(lasers, 6))
return priority
return sorted(priority)
def get_priority_hint_locations(_: "WitnessWorld"):
return {
def get_priority_hint_locations(_: "WitnessWorld") -> List[str]:
return [
"Swamp Purple Underwater",
"Shipwreck Vault Box",
"Town RGB Room Left",
@@ -264,7 +264,7 @@ def get_priority_hint_locations(_: "WitnessWorld"):
"Tunnels Theater Flowers EP",
"Boat Shipwreck Green EP",
"Quarry Stoneworks Control Room Left",
}
]
def make_hint_from_item(world: "WitnessWorld", item_name: str, own_itempool: List[Item]):
@@ -365,8 +365,8 @@ def make_hints(world: "WitnessWorld", hint_amount: int, own_itempool: List[Item]
remaining_hints = hint_amount - len(hints)
priority_hint_amount = int(max(0.0, min(len(priority_hint_pairs) / 2, remaining_hints / 2)))
prog_items_in_this_world = sorted(list(prog_items_in_this_world))
locations_in_this_world = sorted(list(loc_in_this_world))
prog_items_in_this_world = sorted(prog_items_in_this_world)
locations_in_this_world = sorted(loc_in_this_world)
world.random.shuffle(prog_items_in_this_world)
world.random.shuffle(locations_in_this_world)

View File

@@ -115,6 +115,7 @@ class WitnessPlayerItems:
# Adjust item classifications based on game settings.
eps_shuffled = self._world.options.shuffle_EPs
come_to_you = self._world.options.elevators_come_to_you
difficulty = self._world.options.puzzle_randomization
for item_name, item_data in self.item_data.items():
if not eps_shuffled and item_name in {"Monastery Garden Entry (Door)",
"Monastery Shortcuts",
@@ -130,10 +131,12 @@ class WitnessPlayerItems:
"Monastery Laser Shortcut (Door)",
"Orchard Second Gate (Door)",
"Jungle Bamboo Laser Shortcut (Door)",
"Keep Pressure Plates 2 Exit (Door)",
"Caves Elevator Controls (Panel)"}:
# Downgrade doors that don't gate progress.
item_data.classification = ItemClassification.useful
elif item_name == "Keep Pressure Plates 2 Exit (Door)" and not (difficulty == "none" and eps_shuffled):
# PP2EP requires the door in vanilla puzzles, otherwise it's unnecessary
item_data.classification = ItemClassification.useful
# Build the mandatory item list.
self._mandatory_items: Dict[str, int] = {}