Compare commits

..

15 Commits

Author SHA1 Message Date
Chris Wilson
a169649500 Use world.web.options_presets directly instead of creating an empty dict first 2024-05-20 00:35:46 -04:00
Chris Wilson
5910b94deb Update options pages macros to respect valid_keys for item and location options (#3347) 2024-05-20 00:26:42 -04:00
Fabian Dill
14ffd1c70c Subnautica: fix use of _valid_keys were valid_keys should be used. (#3346)
* Subnautica: fix use of _valid_keys were valid_keys should be used.

* Update Options.py
2024-05-20 00:20:01 -04:00
Scipio Wright
754fc11c1b TUNIC: ER Refactor for better plando connections, fewer shops improvement (#3075)
* Fixed shop changes

* Update option description

* Apply suggestions from Vi's review (thank you)

* Fix for plando connections on a full scene

* Plando connections should work better now for complicated paths

* Even more good plando connections yes

* Starting to move the info over

* Fixing up formatting a bit

* Remove unneeded item info

* Put in updated_reachable_regions, to replace add_dependent_regions

* Updated to match ladder shuffle

* More stuff I guess

* It functions!

* It mostly works with plando now, some slight issues still

* Fixed minor logic bug

* Fixed world leakage

* Change exception message

* Make exception message better for troubleshooting failed connections

* Merged with main

* technically a logic fix but it would never matter cause no start shuffle

* Add a couple more alias item groups cause yeah

* Rename beneath the vault front -> beneath the vault main

* Flip lantern access rule to the region

* Add missing connection to traversal reqs

* Move start_inventory_from_pool to the top so that it's next to start_inventory

* Reword the fixed shop description slightly

* Refactor per ixrec's comments

* Greatly reduced an overcomplicated block because Vi is cool and smart and also cool

* Rewrite traversal reqs thing per Vi's comments
2024-05-20 01:01:24 +02:00
Star Rauchenberger
12cde88f95 Lingo: Fixed edge case sunwarp shuffle accessibility issue (#3228)
* Lingo: Fixed edge case sunwarp shuffle accessibility issue

* Minor readability update

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-05-20 00:56:24 +02:00
Alchav
e0b6889634 ALTTP: Second attempt to fix Swamp Palace boss logic (#3315) 2024-05-19 22:18:41 +02:00
black-sliver
14321d6ba2 Factorio: update factorio-rcon (#3198)
2.1.1 didn't work with py3.8, 2.1.2 fixes that
2024-05-19 20:41:18 +02:00
black-sliver
e978109410 WebHost: properly stop worker threads (#3340)
* WebHost: properly stop worker threads

* Less jank

* Forgot the try-catch around the while true
2024-05-19 20:40:36 +02:00
black-sliver
019dfb8242 CustomServer: re-add missing Archipelago to data package (#3341) 2024-05-19 20:40:08 +02:00
Doug Hoskisson
8e9a050889 Zillion: "item counts" OptionGroup (#3338) 2024-05-19 14:36:47 -04:00
Fabian Dill
2801e21296 WebHost: fixup WebHostLib/options.py (#3332)
* WebHost: fixup WebHostLib/options.py

* Update WebHostLib/options.py

* Update WebHostLib/options.py

* fix visibility flag handling
2024-05-19 14:21:46 -04:00
Fabian Dill
e97eddcdaf WebHost: move atexit saving to end of room hosting function (#3339) 2024-05-19 18:25:56 +02:00
Fabian Dill
d3f4ee4994 WebHost: re-introduce per-Room Locker (#3337) 2024-05-19 16:31:35 +02:00
black-sliver
cf34f125d6 CustomServer: don't mutate static server data (#3334)
when switching to multiple rooms per process, we ended up modifying the static server data
because that's how _load works and the data is now shared between multiple rooms.
2024-05-19 15:32:11 +02:00
Fabian Dill
663b50b33e WebHost: fix AutoLauncher restarting rooms due to race condition (#3333) 2024-05-19 15:17:55 +02:00
21 changed files with 1178 additions and 733 deletions

View File

@@ -508,7 +508,7 @@ class Context:
self.logger.exception(e)
self._start_async_saving()
def _start_async_saving(self):
def _start_async_saving(self, atexit_save: bool = True):
if not self.auto_saver_thread:
def save_regularly():
# time.time() is platform dependent, so using the expensive datetime method instead
@@ -532,8 +532,9 @@ class Context:
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
import atexit
atexit.register(self._save, True) # make sure we save on exit too
if atexit_save:
import atexit
atexit.register(self._save, True) # make sure we save on exit too
def get_save(self) -> dict:
self.recheck_hints()

View File

@@ -746,6 +746,7 @@ class NamedRange(Range):
class FreezeValidKeys(AssembleOptions):
def __new__(mcs, name, bases, attrs):
assert not "_valid_keys" in attrs, "'_valid_keys' gets set by FreezeValidKeys, define 'valid_keys' instead."
if "valid_keys" in attrs:
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)

View File

@@ -117,7 +117,7 @@ if __name__ == "__main__":
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.options import create as create_options_files
try:
@@ -138,3 +138,11 @@ if __name__ == "__main__":
else:
from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
else:
from time import sleep
try:
while True:
sleep(1) # wait for process to be killed
except (SystemExit, KeyboardInterrupt):
pass
stop() # stop worker threads

View File

@@ -3,16 +3,26 @@ from __future__ import annotations
import json
import logging
import multiprocessing
import time
import typing
from uuid import UUID
from datetime import timedelta, datetime
from threading import Event, Thread
from uuid import UUID
from pony.orm import db_session, select, commit
from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
_stop_event = Event()
def stop():
"""Stops previously launched threads"""
global _stop_event
stop_event = _stop_event
_stop_event = Event() # new event for new threads
stop_event.set()
def handle_generation_success(seed_id):
logging.info(f"Generation finished for seed {seed_id}")
@@ -63,6 +73,7 @@ def cleanup():
def autohost(config: dict):
def keep_running():
stop_event = _stop_event
try:
with Locker("autohost"):
cleanup()
@@ -72,26 +83,25 @@ def autohost(config: dict):
hosters.append(hoster)
hoster.start()
while 1:
time.sleep(0.1)
while not stop_event.wait(0.1):
with db_session:
rooms = select(
room for room in Room if
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 >= datetime.utcnow() - timedelta(seconds=room.timeout):
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException:
logging.info("Autohost reports as already running, not starting another.")
import threading
threading.Thread(target=keep_running, name="AP_Autohost").start()
Thread(target=keep_running, name="AP_Autohost").start()
def autogen(config: dict):
def keep_running():
stop_event = _stop_event
try:
with Locker("autogen"):
@@ -112,8 +122,7 @@ def autogen(config: dict):
commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
while 1:
time.sleep(0.1)
while not stop_event.wait(0.1):
with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select(
@@ -124,8 +133,7 @@ def autogen(config: dict):
except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.")
import threading
threading.Thread(target=keep_running, name="AP_Autogen").start()
Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}

View File

@@ -74,6 +74,7 @@ class WebHostContext(Context):
def _load_game_data(self):
for key, value in self.static_server_data.items():
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
@@ -101,18 +102,37 @@ class WebHostContext(Context):
multidata = self.decompress(room.seed.multidata)
game_data_packages = {}
static_gamespackage = self.gamespackage # this is shared across all rooms
static_item_name_groups = self.item_name_groups
static_location_name_groups = self.location_name_groups
self.gamespackage = {"Archipelago": static_gamespackage["Archipelago"]} # this may be modified by _load
self.item_name_groups = {}
self.location_name_groups = {}
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata and use static data
# games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game]
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
@db_session
@@ -122,7 +142,7 @@ class WebHostContext(Context):
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving()
self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
@@ -212,59 +232,62 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
loop = asyncio.get_event_loop()
async def start_room(room_id):
try:
logger = set_up_logging(room_id)
ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id)
ctx.init_save()
with Locker(f"RoomLocker {room_id}"):
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
logger = set_up_logging(room_id)
ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id)
ctx.init_save()
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
port = socketname[1]
if port:
ctx.logger.info(f'Hosting game at {host}:{port}')
if port:
ctx.logger.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
# ensure auto launch is on the same page in regard to room activity.
with db_session:
room: Room = Room.get(id=ctx.room_id)
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
except (KeyboardInterrupt, SystemExit):
with db_session:
room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except Exception:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
raise
finally:
rooms_shutting_down.put(room_id)
except (KeyboardInterrupt, SystemExit):
pass
except Exception:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
raise
finally:
try:
ctx._save()
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)
rooms_shutting_down.put(room_id)
class Starter(threading.Thread):
def run(self):

View File

@@ -70,37 +70,41 @@ def generate(race=False):
flash(options)
else:
meta = get_meta(request.form, race)
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
return start_generation(options, meta)
return render_template("generate.html", race=race, version=__version__)
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}

View File

@@ -1,34 +1,33 @@
import collections.abc
import os
import yaml
import requests
import json
import flask
from urllib.parse import urlparse
import os
from textwrap import dedent
from typing import Dict, Union
import yaml
from flask import redirect, render_template, request, Response
import Options
from Options import Visibility
from flask import redirect, render_template, request, Response
from worlds.AutoWorld import AutoWorldRegister
from Utils import local_path
from textwrap import dedent
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
def create():
def create() -> None:
target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs")
Options.generate_yaml_templates(yaml_folder)
def get_world_theme(game_name: str):
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def render_options_page(template: str, world_name: str, is_complex: bool = False):
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
return redirect("games")
@@ -40,13 +39,8 @@ def render_options_page(template: str, world_name: str, is_complex: bool = False
grouped_options = {group: {} for group in ordered_groups}
for option_name, option in world.options_dataclass.type_hints.items():
# Exclude settings from options pages if their visibility is disabled
if not is_complex and option.visibility < Visibility.simple_ui:
continue
if is_complex and option.visibility < Visibility.complex_ui:
continue
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
if visibility_flag in option.visibility:
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
return render_template(
template,
@@ -59,29 +53,12 @@ def render_options_page(template: str, world_name: str, is_complex: bool = False
)
def generate_game(player_name: str, formatted_options: dict):
payload = {
"race": 0,
"hint_cost": 10,
"forfeit_mode": "auto",
"remaining_mode": "disabled",
"collect_mode": "goal",
"weights": {
player_name: formatted_options,
},
}
url = urlparse(request.base_url)
port_string = f":{url.port}" if url.port else ""
r = requests.post(f"{url.scheme}://{url.hostname}{port_string}/api/generate", json=payload)
if 200 <= r.status_code <= 299:
response_data = r.json()
return redirect(response_data["url"])
else:
return r.text
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
from .generate import start_generation
return start_generation(options, {"plando_options": ["items", "connections", "texts", "bosses"]})
def send_yaml(player_name: str, formatted_options: dict):
def send_yaml(player_name: str, formatted_options: dict) -> Response:
response = Response(yaml.dump(formatted_options, sort_keys=False))
response.headers["Content-Type"] = "text/yaml"
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
@@ -89,7 +66,7 @@ def send_yaml(player_name: str, formatted_options: dict):
@app.template_filter("dedent")
def filter_dedent(text: str):
def filter_dedent(text: str) -> str:
return dedent(text).strip("\n ")
@@ -102,10 +79,6 @@ def test_ordered(obj):
@cache.cached()
def option_presets(game: str) -> Response:
world = AutoWorldRegister.world_types[game]
presets = {}
if world.web.options_presets:
presets = presets | world.web.options_presets
class SetEncoder(json.JSONEncoder):
def default(self, obj):
@@ -114,8 +87,8 @@ def option_presets(game: str) -> Response:
return list(obj)
return json.JSONEncoder.default(self, obj)
json_data = json.dumps(presets, cls=SetEncoder)
response = flask.Response(json_data)
json_data = json.dumps(world.web.options_presets, cls=SetEncoder)
response = Response(json_data)
response.headers["Content-Type"] = "application/json"
return response
@@ -173,7 +146,7 @@ def generate_weighted_yaml(game: str):
}
if intent_generate:
return generate_game(player_name, formatted_options)
return generate_game({player_name: formatted_options})
else:
return send_yaml(player_name, formatted_options)
@@ -247,7 +220,7 @@ def generate_yaml(game: str):
}
if intent_generate:
return generate_game(player_name, formatted_options)
return generate_game({player_name: formatted_options})
else:
return send_yaml(player_name, formatted_options)

View File

@@ -114,7 +114,7 @@
{% macro ItemDict(option_name, option, world) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in world.item_names|sort %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="option-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
@@ -149,7 +149,7 @@
{% if world.location_name_groups.keys()|length > 1 %}
<div class="option-divider">&nbsp;</div>
{% endif %}
{% for location_name in world.location_names|sort %}
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ location_name }}" name="{{ option_name }}" value="{{ location_name }}" {{ "checked" if location_name in option.default }} />
<label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
@@ -172,7 +172,7 @@
{% if world.item_name_groups.keys()|length > 1 %}
<div class="option-divider">&nbsp;</div>
{% endif %}
{% for item_name in world.item_names|sort %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ item_name }}" name="{{ option_name }}" value="{{ item_name }}" {{ "checked" if item_name in option.default }} />
<label for="{{ option_name }}-{{ item_name }}">{{ item_name }}</label>

View File

@@ -105,7 +105,7 @@
{% macro ItemDict(option_name, option, world) %}
<div class="dict-container">
{% for item_name in world.item_names|sort %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input
@@ -150,7 +150,7 @@
{% if world.location_name_groups.keys()|length > 1 %}
<div class="divider">&nbsp;</div>
{% endif %}
{% for location_name in world.location_names|sort %}
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ location_name }}" name="{{ option_name }}||{{ location_name }}" value="1" {{ "checked" if location_name in option.default }} />
<label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
@@ -172,7 +172,7 @@
{% if world.item_name_groups.keys()|length > 1 %}
<div class="set-divider">&nbsp;</div>
{% endif %}
{% for item_name in world.item_names|sort %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ item_name }}" name="{{ option_name }}||{{ item_name }}" value="1" {{ "checked" if item_name in option.default }} />
<label for="{{ option_name }}-{{ item_name }}">{{ item_name }}</label>

View File

@@ -399,8 +399,8 @@ def global_rules(multiworld: MultiWorld, player: int):
set_rule(multiworld.get_entrance('Swamp Palace (North)', player), lambda state: state.has('Hookshot', player) and state._lttp_has_key('Small Key (Swamp Palace)', player, 5))
if not multiworld.small_key_shuffle[player] and multiworld.glitches_required[player] not in ['hybrid_major_glitches', 'no_logic']:
forbid_item(multiworld.get_location('Swamp Palace - Entrance', player), 'Big Key (Swamp Palace)', player)
set_rule(multiworld.get_location('Swamp Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
set_rule(multiworld.get_location('Swamp Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
add_rule(multiworld.get_location('Swamp Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
add_rule(multiworld.get_location('Swamp Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
if multiworld.pot_shuffle[player]:
# key can (and probably will) be moved behind bombable wall
set_rule(multiworld.get_location('Swamp Palace - Waterway Pot Key', player), lambda state: can_use_bombs(state, player))

View File

@@ -1,2 +1 @@
factorio-rcon-py>=2.1.1; python_version >= '3.9'
factorio-rcon-py==2.0.1; python_version <= '3.8'
factorio-rcon-py>=2.1.2

View File

@@ -2,7 +2,7 @@ from enum import Enum
from typing import Dict, List, NamedTuple, Optional, Set, Tuple, TYPE_CHECKING
from Options import OptionError
from .datatypes import Door, DoorType, RoomAndDoor, RoomAndPanel
from .datatypes import Door, DoorType, Painting, RoomAndDoor, RoomAndPanel
from .items import ALL_ITEM_TABLE, ItemType
from .locations import ALL_LOCATION_TABLE, LocationClassification
from .options import LocationChecks, ShuffleDoors, SunwarpAccess, VictoryCondition
@@ -361,13 +361,29 @@ class LingoPlayerLogic:
if door_shuffle == ShuffleDoors.option_none:
required_painting_rooms += REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS
req_exits = [painting_id for painting_id, painting in PAINTINGS.items() if painting.required_when_no_doors]
req_enterable = [painting_id for painting_id, painting in PAINTINGS.items()
if not painting.exit_only and not painting.disable and not painting.req_blocked and
not painting.req_blocked_when_no_doors and painting.room not in required_painting_rooms]
else:
req_enterable = [painting_id for painting_id, painting in PAINTINGS.items()
if not painting.exit_only and not painting.disable and not painting.req_blocked and
painting.room not in required_painting_rooms]
def is_req_enterable(painting_id: str, painting: Painting) -> bool:
if painting.exit_only or painting.disable or painting.req_blocked\
or painting.room in required_painting_rooms:
return False
if world.options.shuffle_doors == ShuffleDoors.option_none:
if painting.req_blocked_when_no_doors:
return False
# Special case for the paintings in Color Hunt and Champion's Rest. These are req blocked when not on
# doors mode, and when sunwarps are disabled or sunwarp shuffle is on and the Color Hunt sunwarp is not
# an exit. This is because these two rooms would then be inaccessible without roof access, and we can't
# hide the Owl Hallway entrance behind roof access.
if painting.room in ["Color Hunt", "Champion's Rest"]:
if world.options.sunwarp_access == SunwarpAccess.option_disabled\
or (world.options.shuffle_sunwarps and "Color Hunt" not in self.sunwarp_exits):
return False
return True
req_enterable = [painting_id for painting_id, painting in PAINTINGS.items()
if is_req_enterable(painting_id, painting)]
req_exits += [painting_id for painting_id, painting in PAINTINGS.items()
if painting.exit_only and painting.required]
req_entrances = world.random.sample(req_enterable, len(req_exits))

View File

@@ -120,7 +120,7 @@ class FillerItemsDistribution(ItemDict):
"""Random chance weights of various filler resources that can be obtained.
Available items: """
__doc__ += ", ".join(f"\"{item_name}\"" for item_name in item_names_by_type[ItemType.resource])
_valid_keys = frozenset(item_names_by_type[ItemType.resource])
valid_keys = sorted(item_names_by_type[ItemType.resource])
default = {item_name: 1 for item_name in item_names_by_type[ItemType.resource]}
display_name = "Filler Items Distribution"

File diff suppressed because it is too large Load Diff

View File

@@ -268,7 +268,8 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re
connecting_region=regions["Overworld Well Ladder"],
rule=lambda state: has_ladder("Ladders in Well", state, player, options))
regions["Overworld Well Ladder"].connect(
connecting_region=regions["Overworld"])
connecting_region=regions["Overworld"],
rule=lambda state: has_ladder("Ladders in Well", state, player, options))
# nmg: can ice grapple through the door
regions["Overworld"].connect(
@@ -706,17 +707,18 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re
connecting_region=regions["Fortress Exterior from Overworld"])
regions["Beneath the Vault Ladder Exit"].connect(
connecting_region=regions["Beneath the Vault Front"],
rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, player, options))
regions["Beneath the Vault Front"].connect(
connecting_region=regions["Beneath the Vault Main"],
rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, player, options)
and has_lantern(state, player, options))
regions["Beneath the Vault Main"].connect(
connecting_region=regions["Beneath the Vault Ladder Exit"],
rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, player, options))
regions["Beneath the Vault Front"].connect(
connecting_region=regions["Beneath the Vault Back"],
rule=lambda state: has_lantern(state, player, options))
regions["Beneath the Vault Main"].connect(
connecting_region=regions["Beneath the Vault Back"])
regions["Beneath the Vault Back"].connect(
connecting_region=regions["Beneath the Vault Front"])
connecting_region=regions["Beneath the Vault Main"],
rule=lambda state: has_lantern(state, player, options))
regions["Fortress East Shortcut Upper"].connect(
connecting_region=regions["Fortress East Shortcut Lower"])
@@ -870,6 +872,9 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re
regions["Rooted Ziggurat Portal Room Entrance"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"])
regions["Zig Skip Exit"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Rooted Ziggurat Portal"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room Exit"],
rule=lambda state: state.has("Activate Ziggurat Fuse", player))
@@ -1453,8 +1458,6 @@ def set_er_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int])
# Beneath the Vault
set_rule(multiworld.get_location("Beneath the Fortress - Bridge", player),
lambda state: state.has_group("Melee Weapons", player, 1) or state.has_any({laurels, fire_wand}, player))
set_rule(multiworld.get_location("Beneath the Fortress - Obscured Behind Waterfall", player),
lambda state: has_lantern(state, player, options))
# Quarry
set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player),

View File

@@ -1,12 +1,12 @@
from typing import Dict, List, Set, TYPE_CHECKING
from BaseClasses import Region, ItemClassification, Item, Location
from .locations import location_table
from .er_data import Portal, tunic_er_regions, portal_mapping, \
dependent_regions_restricted, dependent_regions_nmg, dependent_regions_ur
from .er_data import Portal, tunic_er_regions, portal_mapping, traversal_requirements, DeadEnd
from .er_rules import set_er_region_rules
from .options import EntranceRando
from worlds.generic import PlandoConnection
from random import Random
from copy import deepcopy
if TYPE_CHECKING:
from . import TunicWorld
@@ -95,7 +95,8 @@ def place_event_items(world: "TunicWorld", regions: Dict[str, Region]) -> None:
def vanilla_portals() -> Dict[Portal, Portal]:
portal_pairs: Dict[Portal, Portal] = {}
portal_map = portal_mapping.copy()
# we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here
portal_map = [portal for portal in portal_mapping if portal.name != "Ziggurat Lower Falling Entrance"]
while portal_map:
portal1 = portal_map[0]
@@ -130,9 +131,13 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
dead_ends: List[Portal] = []
two_plus: List[Portal] = []
player_name = world.multiworld.get_player_name(world.player)
portal_map = portal_mapping.copy()
logic_rules = world.options.logic_rules.value
fixed_shop = world.options.fixed_shop
laurels_location = world.options.laurels_location
traversal_reqs = deepcopy(traversal_requirements)
has_laurels = True
waterfall_plando = False
# if it's not one of the EntranceRando options, it's a custom seed
if world.options.entrance_rando.value not in EntranceRando.options:
@@ -140,38 +145,53 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
logic_rules = seed_group["logic_rules"]
fixed_shop = seed_group["fixed_shop"]
laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False
# marking that you don't immediately have laurels
if laurels_location == "10_fairies" and not hasattr(world.multiworld, "re_gen_passthrough"):
has_laurels = False
shop_scenes: Set[str] = set()
shop_count = 6
if fixed_shop:
shop_count = 1
shop_count = 0
shop_scenes.add("Overworld Redux")
if not logic_rules:
dependent_regions = dependent_regions_restricted
elif logic_rules == 1:
dependent_regions = dependent_regions_nmg
else:
dependent_regions = dependent_regions_ur
# if fixed shop is off, remove this portal
for portal in portal_map:
if portal.region == "Zig Skip Exit":
portal_map.remove(portal)
break
# create separate lists for dead ends and non-dead ends
if logic_rules:
for portal in portal_mapping:
if tunic_er_regions[portal.region].dead_end == 1:
dead_ends.append(portal)
else:
for portal in portal_map:
dead_end_status = tunic_er_regions[portal.region].dead_end
if dead_end_status == DeadEnd.free:
two_plus.append(portal)
elif dead_end_status == DeadEnd.all_cats:
dead_ends.append(portal)
elif dead_end_status == DeadEnd.restricted:
if logic_rules:
two_plus.append(portal)
else:
for portal in portal_mapping:
if tunic_er_regions[portal.region].dead_end:
dead_ends.append(portal)
else:
two_plus.append(portal)
dead_ends.append(portal)
# these two get special handling
elif dead_end_status == DeadEnd.special:
if portal.region == "Secret Gathering Place":
if laurels_location == "10_fairies":
two_plus.append(portal)
else:
dead_ends.append(portal)
if portal.region == "Zig Skip Exit":
if fixed_shop:
two_plus.append(portal)
else:
dead_ends.append(portal)
connected_regions: Set[str] = set()
# make better start region stuff when/if implementing random start
start_region = "Overworld"
connected_regions.update(add_dependent_regions(start_region, logic_rules))
connected_regions.add(start_region)
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules)
if world.options.entrance_rando.value in EntranceRando.options:
plando_connections = world.multiworld.plando_connections[world.player]
@@ -205,11 +225,17 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
non_dead_end_regions.add(region_name)
elif region_info.dead_end == 2 and logic_rules:
non_dead_end_regions.add(region_name)
elif region_info.dead_end == 3:
if (region_name == "Secret Gathering Place" and laurels_location == "10_fairies") \
or (region_name == "Zig Skip Exit" and fixed_shop):
non_dead_end_regions.add(region_name)
if plando_connections:
for connection in plando_connections:
p_entrance = connection.entrance
p_exit = connection.exit
portal1_dead_end = True
portal2_dead_end = True
portal1 = None
portal2 = None
@@ -218,8 +244,10 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
for portal in two_plus:
if p_entrance == portal.name:
portal1 = portal
portal1_dead_end = False
if p_exit == portal.name:
portal2 = portal
portal2_dead_end = False
# search dead_ends individually since we can't really remove items from two_plus during the loop
if portal1:
@@ -233,7 +261,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
else:
raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.")
for portal in dead_ends:
if p_entrance == portal.name:
portal1 = portal
@@ -246,7 +274,6 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
if portal2:
two_plus.remove(portal2)
else:
# check if portal2 is a dead end
for portal in dead_ends:
if p_exit == portal.name:
portal2 = portal
@@ -256,6 +283,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
portal2 = Portal(name="Shop Portal", region="Shop",
destination="Previous Region", tag="_")
shop_count -= 1
# need to maintain an even number of portals total
if shop_count < 0:
shop_count += 2
for p in portal_mapping:
@@ -269,48 +297,36 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
f"plando connections in {player_name}'s YAML.")
dead_ends.remove(portal2)
# update the traversal chart to say you can get from portal1's region to portal2's and vice versa
if not portal1_dead_end and not portal2_dead_end:
traversal_reqs.setdefault(portal1.region, dict())[portal2.region] = []
traversal_reqs.setdefault(portal2.region, dict())[portal1.region] = []
if portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit":
if portal1_dead_end or portal2_dead_end or \
portal1.region == "Secret Gathering Place" or portal2.region == "Secret Gathering Place":
if world.options.entrance_rando.value not in EntranceRando.options:
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.")
else:
raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.")
if portal1.region == "Secret Gathering Place" or portal2.region == "Secret Gathering Place":
# need to make sure you didn't pair this to a dead end or zig skip
if portal1_dead_end or portal2_dead_end or \
portal1.region == "Zig Skip Exit" or portal2.region == "Zig Skip Exit":
if world.options.entrance_rando.value not in EntranceRando.options:
raise Exception(f"Tunic ER seed group {world.options.entrance_rando.value} paired a dead "
"end to a dead end in their plando connections.")
else:
raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.")
waterfall_plando = True
portal_pairs[portal1] = portal2
# update dependent regions based on the plando'd connections, to ensure the portals connect well, logically
for origins, destinations in dependent_regions.items():
if portal1.region in origins:
if portal2.region in non_dead_end_regions:
destinations.append(portal2.region)
if portal2.region in origins:
if portal1.region in non_dead_end_regions:
destinations.append(portal1.region)
# if we have plando connections, our connected regions may change somewhat
while True:
test1 = len(connected_regions)
for region in connected_regions.copy():
connected_regions.update(add_dependent_regions(region, logic_rules))
test2 = len(connected_regions)
if test1 == test2:
break
# need to plando fairy cave, or it could end up laurels locked
# fix this later to be random after adding some item logic to dependent regions
if laurels_location == "10_fairies" and not hasattr(world.multiworld, "re_gen_passthrough"):
portal1 = None
portal2 = None
for portal in two_plus:
if portal.scene_destination() == "Overworld Redux, Waterfall_":
portal1 = portal
break
for portal in dead_ends:
if portal.scene_destination() == "Waterfall, Overworld Redux_":
portal2 = portal
break
if not portal1:
raise Exception(f"Failed to do Laurels Location at 10 Fairies option. "
f"Did {player_name} plando connection the Secret Gathering Place Entrance?")
if not portal2:
raise Exception(f"Failed to do Laurels Location at 10 Fairies option. "
f"Did {player_name} plando connection the Secret Gathering Place Exit?")
portal_pairs[portal1] = portal2
two_plus.remove(portal1)
dead_ends.remove(portal2)
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules)
if fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"):
portal1 = None
@@ -339,47 +355,54 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
previous_conn_num = 0
fail_count = 0
while len(connected_regions) < len(non_dead_end_regions):
# if the connected regions length stays unchanged for too long, it's stuck in a loop
# should, hopefully, only ever occur if someone plandos connections poorly
# if this is universal tracker, just break immediately and move on
if hasattr(world.multiworld, "re_gen_passthrough"):
break
# if the connected regions length stays unchanged for too long, it's stuck in a loop
# should, hopefully, only ever occur if someone plandos connections poorly
if previous_conn_num == len(connected_regions):
fail_count += 1
if fail_count >= 500:
raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for loops.")
raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for errors. "
"Unconnected regions:", non_dead_end_regions - connected_regions)
else:
fail_count = 0
previous_conn_num = len(connected_regions)
# find a portal in an inaccessible region
# find a portal in a connected region
if check_success == 0:
for portal in two_plus:
if portal.region in connected_regions:
# if there's risk of self-locking, start over
if gate_before_switch(portal, two_plus):
random_object.shuffle(two_plus)
break
portal1 = portal
two_plus.remove(portal)
check_success = 1
break
# then we find a portal in a connected region
# then we find a portal in an inaccessible region
if check_success == 1:
for portal in two_plus:
if portal.region not in connected_regions:
# if there's risk of self-locking, shuffle and try again
if gate_before_switch(portal, two_plus):
random_object.shuffle(two_plus)
break
# if secret gathering place happens to get paired really late, you can end up running out
if not has_laurels and len(two_plus) < 80:
# if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this
if waterfall_plando:
cr = connected_regions.copy()
cr.add(portal.region)
if "Secret Gathering Place" not in update_reachable_regions(cr, traversal_reqs, has_laurels, logic_rules):
continue
elif portal.region != "Secret Gathering Place":
continue
portal2 = portal
connected_regions.add(portal.region)
two_plus.remove(portal)
check_success = 2
break
# once we have both portals, connect them and add the new region(s) to connected_regions
if check_success == 2:
connected_regions.update(add_dependent_regions(portal2.region, logic_rules))
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules)
if "Secret Gathering Place" in connected_regions:
has_laurels = True
portal_pairs[portal1] = portal2
check_success = 0
random_object.shuffle(two_plus)
@@ -411,7 +434,6 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
portal1 = two_plus.pop()
portal2 = dead_ends.pop()
portal_pairs[portal1] = portal2
# then randomly connect the remaining portals to each other
# every region is accessible, so gate_before_switch is not necessary
while len(two_plus) > 1:
@@ -438,126 +460,42 @@ def create_randomized_entrances(portal_pairs: Dict[Portal, Portal], regions: Dic
region2.connect(connecting_region=region1, name=portal2.name)
# loop through the static connections, return regions you can reach from this region
# todo: refactor to take region_name and dependent_regions
def add_dependent_regions(region_name: str, logic_rules: int) -> Set[str]:
region_set = set()
if not logic_rules:
regions_to_add = dependent_regions_restricted
elif logic_rules == 1:
regions_to_add = dependent_regions_nmg
else:
regions_to_add = dependent_regions_ur
for origin_regions, destination_regions in regions_to_add.items():
if region_name in origin_regions:
# if you matched something in the first set, you get the regions in its paired set
region_set.update(destination_regions)
return region_set
# if you didn't match anything in the first sets, just gives you the region
region_set = {region_name}
return region_set
def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[str, Dict[str, List[List[str]]]],
has_laurels: bool, logic: int) -> Set[str]:
# starting count, so we can run it again if this changes
region_count = len(connected_regions)
for origin, destinations in traversal_reqs.items():
if origin not in connected_regions:
continue
# check if we can traverse to any of the destinations
for destination, req_lists in destinations.items():
if destination in connected_regions:
continue
met_traversal_reqs = False
if len(req_lists) == 0:
met_traversal_reqs = True
# loop through each set of possible requirements, with a fancy for else loop
for reqs in req_lists:
for req in reqs:
if req == "Hyperdash":
if not has_laurels:
break
elif req == "NMG":
if not logic:
break
elif req == "UR":
if logic < 2:
break
elif req not in connected_regions:
break
else:
met_traversal_reqs = True
break
if met_traversal_reqs:
connected_regions.add(destination)
# if the length of connected_regions changed, we got new regions, so we want to check those new origins
if region_count != len(connected_regions):
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic)
# we're checking if an event-locked portal is being placed before the regions where its key(s) is/are
# doing this ensures the keys will not be locked behind the event-locked portal
def gate_before_switch(check_portal: Portal, two_plus: List[Portal]) -> bool:
# the western belltower cannot be locked since you can access it with laurels
# so we only need to make sure the forest belltower isn't locked
if check_portal.scene_destination() == "Overworld Redux, Temple_main":
i = 0
for portal in two_plus:
if portal.region == "Forest Belltower Upper":
i += 1
break
if i == 1:
return True
# fortress big gold door needs 2 scenes and one of the two upper portals of the courtyard
elif check_portal.scene_destination() == "Fortress Main, Fortress Arena_":
i = j = k = 0
for portal in two_plus:
if portal.region == "Fortress Courtyard Upper":
i += 1
if portal.scene() == "Fortress Basement":
j += 1
if portal.region == "Eastern Vault Fortress":
k += 1
if i == 2 or j == 2 or k == 5:
return True
# fortress teleporter needs only the left fuses
elif check_portal.scene_destination() in {"Fortress Arena, Transit_teleporter_spidertank",
"Transit, Fortress Arena_teleporter_spidertank"}:
i = j = k = 0
for portal in two_plus:
if portal.scene() == "Fortress Courtyard":
i += 1
if portal.scene() == "Fortress Basement":
j += 1
if portal.region == "Eastern Vault Fortress":
k += 1
if i == 8 or j == 2 or k == 5:
return True
# Cathedral door needs Overworld and the front of Swamp
# Overworld is currently guaranteed, so no need to check it
elif check_portal.scene_destination() == "Swamp Redux 2, Cathedral Redux_main":
i = 0
for portal in two_plus:
if portal.region in {"Swamp Front", "Swamp to Cathedral Treasure Room",
"Swamp to Cathedral Main Entrance Region"}:
i += 1
if i == 4:
return True
# Zig portal room exit needs Zig 3 to be accessible to hit the fuse
elif check_portal.scene_destination() == "ziggurat2020_FTRoom, ziggurat2020_3_":
i = 0
for portal in two_plus:
if portal.scene() == "ziggurat2020_3":
i += 1
if i == 2:
return True
# Quarry teleporter needs you to hit the Darkwoods fuse
# Since it's physically in Quarry, we don't need to check for it
elif check_portal.scene_destination() in {"Quarry Redux, Transit_teleporter_quarry teleporter",
"Quarry Redux, ziggurat2020_0_"}:
i = 0
for portal in two_plus:
if portal.scene() == "Darkwoods Tunnel":
i += 1
if i == 2:
return True
# Same as above, but Quarry isn't guaranteed here
elif check_portal.scene_destination() == "Transit, Quarry Redux_teleporter_quarry teleporter":
i = j = 0
for portal in two_plus:
if portal.scene() == "Darkwoods Tunnel":
i += 1
if portal.scene() == "Quarry Redux":
j += 1
if i == 2 or j == 7:
return True
# Need Library fuse to use this teleporter
elif check_portal.scene_destination() == "Transit, Library Lab_teleporter_library teleporter":
i = 0
for portal in two_plus:
if portal.scene() == "Library Lab":
i += 1
if i == 3:
return True
# Need West Garden fuse to use this teleporter
elif check_portal.scene_destination() == "Transit, Archipelagos Redux_teleporter_archipelagos_teleporter":
i = 0
for portal in two_plus:
if portal.scene() == "Archipelagos Redux":
i += 1
if i == 6:
return True
# false means you're good to place the portal
return False
return connected_regions

View File

@@ -237,6 +237,8 @@ extra_groups: Dict[str, Set[str]] = {
"Ladder to Atoll": {"Ladder to Ruined Atoll"}, # fuzzy matching made it hint Ladders in Well, now it won't
"Ladders to Bell": {"Ladders to West Bell"},
"Ladders to Well": {"Ladders in Well"}, # fuzzy matching decided ladders in well was ladders to west bell
"Ladders in Atoll": {"Ladders in South Atoll"},
"Ladders in Ruined Atoll": {"Ladders in South Atoll"},
}
item_name_groups.update(extra_groups)

View File

@@ -86,7 +86,7 @@ location_table: Dict[str, TunicLocationData] = {
"Hero's Grave - Flowers Relic": TunicLocationData("Eastern Vault Fortress", "Hero Relic - Fortress"),
"Beneath the Fortress - Bridge": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Cell Chest 1": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Obscured Behind Waterfall": TunicLocationData("Beneath the Vault", "Beneath the Vault Front"),
"Beneath the Fortress - Obscured Behind Waterfall": TunicLocationData("Beneath the Vault", "Beneath the Vault Main"),
"Beneath the Fortress - Back Room Chest": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Cell Chest 2": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain"),

View File

@@ -118,7 +118,8 @@ class EntranceRando(TextChoice):
class FixedShop(Toggle):
"""Forces the Windmill entrance to lead to a shop, and places only one other shop in the pool.
"""Forces the Windmill entrance to lead to a shop, and removes the remaining shops from the pool.
Adds another entrance in Rooted Ziggurat Lower to keep an even number of entrances.
Has no effect if Entrance Rando is not enabled."""
internal_name = "fixed_shop"
display_name = "Fewer Shops in Entrance Rando"
@@ -126,8 +127,7 @@ class FixedShop(Toggle):
class LaurelsLocation(Choice):
"""Force the Hero's Laurels to be placed at a location in your world.
For if you want to avoid or specify early or late Laurels.
If you use the 10 Fairies option in Entrance Rando, Secret Gathering Place will be at its vanilla entrance."""
For if you want to avoid or specify early or late Laurels."""
internal_name = "laurels_location"
display_name = "Laurels Location"
option_anywhere = 0
@@ -147,6 +147,7 @@ class ShuffleLadders(Toggle):
@dataclass
class TunicOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
sword_progression: SwordProgression
start_with_sword: StartWithSword
keys_behind_bosses: KeysBehindBosses
@@ -162,4 +163,3 @@ class TunicOptions(PerGameCommonOptions):
lanternless: Lanternless
maskless: Maskless
laurels_location: LaurelsLocation
start_inventory_from_pool: StartInventoryPool

View File

@@ -14,7 +14,7 @@ from BaseClasses import ItemClassification, LocationProgressType, \
from .gen_data import GenData
from .logic import cs_to_zz_locs
from .region import ZillionLocation, ZillionRegion
from .options import ZillionOptions, validate
from .options import ZillionOptions, validate, z_option_groups
from .id_maps import ZillionSlotInfo, get_slot_info, item_name_to_id as _item_name_to_id, \
loc_name_to_id as _loc_name_to_id, make_id_to_others, \
zz_reg_name_to_reg_name, base_id
@@ -62,6 +62,8 @@ class ZillionWebWorld(WebWorld):
["beauxq"]
)]
option_groups = z_option_groups
class ZillionWorld(World):
"""

View File

@@ -3,7 +3,7 @@ from dataclasses import dataclass
from typing import ClassVar, Dict, Tuple
from typing_extensions import TypeGuard # remove when Python >= 3.10
from Options import DefaultOnToggle, NamedRange, PerGameCommonOptions, Range, Toggle, Choice
from Options import Choice, DefaultOnToggle, NamedRange, OptionGroup, PerGameCommonOptions, Range, Toggle
from zilliandomizer.options import (
Options as ZzOptions, char_to_gun, char_to_jump, ID,
@@ -279,6 +279,14 @@ class ZillionOptions(PerGameCommonOptions):
room_gen: ZillionRoomGen
z_option_groups = [
OptionGroup("item counts", [
ZillionIDCardCount, ZillionBreadCount, ZillionOpaOpaCount, ZillionZillionCount,
ZillionFloppyDiskCount, ZillionScopeCount, ZillionRedIDCardCount
])
]
def convert_item_counts(ic: "Counter[str]") -> ZzItemCounts:
tr: ZzItemCounts = {
ID.card: ic["ID Card"],