Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Wilson
f33f19f8b2 Fix options pages not redirecting to appropriate host url for /api/generate 2024-05-19 00:21:31 -04:00
21 changed files with 733 additions and 1178 deletions

View File

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

View File

@@ -746,7 +746,6 @@ class NamedRange(Range):
class FreezeValidKeys(AssembleOptions): class FreezeValidKeys(AssembleOptions):
def __new__(mcs, name, bases, attrs): 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: if "valid_keys" in attrs:
attrs["_valid_keys"] = frozenset(attrs["valid_keys"]) attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs) 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) logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
try: try:
@@ -138,11 +138,3 @@ if __name__ == "__main__":
else: else:
from waitress import serve from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) 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,26 +3,16 @@ from __future__ import annotations
import json import json
import logging import logging
import multiprocessing import multiprocessing
import time
import typing import typing
from datetime import timedelta, datetime
from threading import Event, Thread
from uuid import UUID from uuid import UUID
from datetime import timedelta, datetime
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
from Utils import restricted_loads from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException 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): def handle_generation_success(seed_id):
logging.info(f"Generation finished for seed {seed_id}") logging.info(f"Generation finished for seed {seed_id}")
@@ -73,7 +63,6 @@ def cleanup():
def autohost(config: dict): def autohost(config: dict):
def keep_running(): def keep_running():
stop_event = _stop_event
try: try:
with Locker("autohost"): with Locker("autohost"):
cleanup() cleanup()
@@ -83,25 +72,26 @@ def autohost(config: dict):
hosters.append(hoster) hosters.append(hoster)
hoster.start() hoster.start()
while not stop_event.wait(0.1): while 1:
time.sleep(0.1)
with db_session: with db_session:
rooms = select( rooms = select(
room for room in Room if room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(days=3)) room.last_activity >= datetime.utcnow() - timedelta(days=3))
for room in rooms: for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled. # we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5): if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
hosters[room.id.int % len(hosters)].start_room(room.id) hosters[room.id.int % len(hosters)].start_room(room.id)
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autohost reports as already running, not starting another.") logging.info("Autohost reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autohost").start() import threading
threading.Thread(target=keep_running, name="AP_Autohost").start()
def autogen(config: dict): def autogen(config: dict):
def keep_running(): def keep_running():
stop_event = _stop_event
try: try:
with Locker("autogen"): with Locker("autogen"):
@@ -122,7 +112,8 @@ def autogen(config: dict):
commit() commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete() select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
while not stop_event.wait(0.1): while 1:
time.sleep(0.1)
with db_session: with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere # for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select( to_start = select(
@@ -133,7 +124,8 @@ def autogen(config: dict):
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.") logging.info("Autogen reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autogen").start() import threading
threading.Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {} multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}

View File

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

View File

@@ -70,41 +70,37 @@ def generate(race=False):
flash(options) flash(options)
else: else:
meta = get_meta(request.form, race) meta = get_meta(request.form, race)
return start_generation(options, meta) 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 render_template("generate.html", race=race, version=__version__) 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): def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta: if not meta:
meta: Dict[str, Any] = {} meta: Dict[str, Any] = {}

View File

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

View File

@@ -114,7 +114,7 @@
{% macro ItemDict(option_name, option, world) %} {% macro ItemDict(option_name, option, world) %}
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="option-container"> <div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} {% for item_name in world.item_names|sort %}
<div class="option-entry"> <div class="option-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label> <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 }}" /> <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 %} {% if world.location_name_groups.keys()|length > 1 %}
<div class="option-divider">&nbsp;</div> <div class="option-divider">&nbsp;</div>
{% endif %} {% endif %}
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %} {% for location_name in world.location_names|sort %}
<div class="option-entry"> <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 }} /> <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> <label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
@@ -172,7 +172,7 @@
{% if world.item_name_groups.keys()|length > 1 %} {% if world.item_name_groups.keys()|length > 1 %}
<div class="option-divider">&nbsp;</div> <div class="option-divider">&nbsp;</div>
{% endif %} {% endif %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} {% for item_name in world.item_names|sort %}
<div class="option-entry"> <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 }} /> <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> <label for="{{ option_name }}-{{ item_name }}">{{ item_name }}</label>

View File

@@ -105,7 +105,7 @@
{% macro ItemDict(option_name, option, world) %} {% macro ItemDict(option_name, option, world) %}
<div class="dict-container"> <div class="dict-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} {% for item_name in world.item_names|sort %}
<div class="dict-entry"> <div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label> <label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input <input
@@ -150,7 +150,7 @@
{% if world.location_name_groups.keys()|length > 1 %} {% if world.location_name_groups.keys()|length > 1 %}
<div class="divider">&nbsp;</div> <div class="divider">&nbsp;</div>
{% endif %} {% endif %}
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %} {% for location_name in world.location_names|sort %}
<div class="set-entry"> <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 }} /> <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> <label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
@@ -172,7 +172,7 @@
{% if world.item_name_groups.keys()|length > 1 %} {% if world.item_name_groups.keys()|length > 1 %}
<div class="set-divider">&nbsp;</div> <div class="set-divider">&nbsp;</div>
{% endif %} {% endif %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} {% for item_name in world.item_names|sort %}
<div class="set-entry"> <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 }} /> <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> <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)) 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']: 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) forbid_item(multiworld.get_location('Swamp Palace - Entrance', player), 'Big Key (Swamp Palace)', player)
add_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 - 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)) set_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]: if multiworld.pot_shuffle[player]:
# key can (and probably will) be moved behind bombable wall # 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)) set_rule(multiworld.get_location('Swamp Palace - Waterway Pot Key', player), lambda state: can_use_bombs(state, player))

View File

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

View File

@@ -2,7 +2,7 @@ from enum import Enum
from typing import Dict, List, NamedTuple, Optional, Set, Tuple, TYPE_CHECKING from typing import Dict, List, NamedTuple, Optional, Set, Tuple, TYPE_CHECKING
from Options import OptionError from Options import OptionError
from .datatypes import Door, DoorType, Painting, RoomAndDoor, RoomAndPanel from .datatypes import Door, DoorType, RoomAndDoor, RoomAndPanel
from .items import ALL_ITEM_TABLE, ItemType from .items import ALL_ITEM_TABLE, ItemType
from .locations import ALL_LOCATION_TABLE, LocationClassification from .locations import ALL_LOCATION_TABLE, LocationClassification
from .options import LocationChecks, ShuffleDoors, SunwarpAccess, VictoryCondition from .options import LocationChecks, ShuffleDoors, SunwarpAccess, VictoryCondition
@@ -361,29 +361,13 @@ class LingoPlayerLogic:
if door_shuffle == ShuffleDoors.option_none: if door_shuffle == ShuffleDoors.option_none:
required_painting_rooms += REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS 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_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()
def is_req_enterable(painting_id: str, painting: Painting) -> bool: if not painting.exit_only and not painting.disable and not painting.req_blocked and
if painting.exit_only or painting.disable or painting.req_blocked\ not painting.req_blocked_when_no_doors and painting.room not in required_painting_rooms]
or painting.room in required_painting_rooms: else:
return False 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
if world.options.shuffle_doors == ShuffleDoors.option_none: painting.room not in required_painting_rooms]
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() req_exits += [painting_id for painting_id, painting in PAINTINGS.items()
if painting.exit_only and painting.required] if painting.exit_only and painting.required]
req_entrances = world.random.sample(req_enterable, len(req_exits)) 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. """Random chance weights of various filler resources that can be obtained.
Available items: """ Available items: """
__doc__ += ", ".join(f"\"{item_name}\"" for item_name in item_names_by_type[ItemType.resource]) __doc__ += ", ".join(f"\"{item_name}\"" for item_name in item_names_by_type[ItemType.resource])
valid_keys = sorted(item_names_by_type[ItemType.resource]) _valid_keys = frozenset(item_names_by_type[ItemType.resource])
default = {item_name: 1 for item_name in 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" display_name = "Filler Items Distribution"

File diff suppressed because it is too large Load Diff

View File

@@ -268,8 +268,7 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re
connecting_region=regions["Overworld Well Ladder"], connecting_region=regions["Overworld Well Ladder"],
rule=lambda state: has_ladder("Ladders in Well", state, player, options)) rule=lambda state: has_ladder("Ladders in Well", state, player, options))
regions["Overworld Well Ladder"].connect( 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 # nmg: can ice grapple through the door
regions["Overworld"].connect( regions["Overworld"].connect(
@@ -707,18 +706,17 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re
connecting_region=regions["Fortress Exterior from Overworld"]) connecting_region=regions["Fortress Exterior from Overworld"])
regions["Beneath the Vault Ladder Exit"].connect( regions["Beneath the Vault Ladder Exit"].connect(
connecting_region=regions["Beneath the Vault Main"], connecting_region=regions["Beneath the Vault Front"],
rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, player, options) rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, player, options))
and has_lantern(state, player, options)) regions["Beneath the Vault Front"].connect(
regions["Beneath the Vault Main"].connect(
connecting_region=regions["Beneath the Vault Ladder Exit"], connecting_region=regions["Beneath the Vault Ladder Exit"],
rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, player, options)) rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, player, options))
regions["Beneath the Vault Main"].connect( regions["Beneath the Vault Front"].connect(
connecting_region=regions["Beneath the Vault Back"]) connecting_region=regions["Beneath the Vault Back"],
regions["Beneath the Vault Back"].connect(
connecting_region=regions["Beneath the Vault Main"],
rule=lambda state: has_lantern(state, player, options)) rule=lambda state: has_lantern(state, player, options))
regions["Beneath the Vault Back"].connect(
connecting_region=regions["Beneath the Vault Front"])
regions["Fortress East Shortcut Upper"].connect( regions["Fortress East Shortcut Upper"].connect(
connecting_region=regions["Fortress East Shortcut Lower"]) connecting_region=regions["Fortress East Shortcut Lower"])
@@ -872,9 +870,6 @@ def set_er_region_rules(world: "TunicWorld", ability_unlocks: Dict[str, int], re
regions["Rooted Ziggurat Portal Room Entrance"].connect( regions["Rooted Ziggurat Portal Room Entrance"].connect(
connecting_region=regions["Rooted Ziggurat Lower Back"]) connecting_region=regions["Rooted Ziggurat Lower Back"])
regions["Zig Skip Exit"].connect(
connecting_region=regions["Rooted Ziggurat Lower Front"])
regions["Rooted Ziggurat Portal"].connect( regions["Rooted Ziggurat Portal"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room Exit"], connecting_region=regions["Rooted Ziggurat Portal Room Exit"],
rule=lambda state: state.has("Activate Ziggurat Fuse", player)) rule=lambda state: state.has("Activate Ziggurat Fuse", player))
@@ -1458,6 +1453,8 @@ def set_er_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int])
# Beneath the Vault # Beneath the Vault
set_rule(multiworld.get_location("Beneath the Fortress - Bridge", player), 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)) 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 # Quarry
set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player), 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 typing import Dict, List, Set, TYPE_CHECKING
from BaseClasses import Region, ItemClassification, Item, Location from BaseClasses import Region, ItemClassification, Item, Location
from .locations import location_table from .locations import location_table
from .er_data import Portal, tunic_er_regions, portal_mapping, traversal_requirements, DeadEnd from .er_data import Portal, tunic_er_regions, portal_mapping, \
dependent_regions_restricted, dependent_regions_nmg, dependent_regions_ur
from .er_rules import set_er_region_rules from .er_rules import set_er_region_rules
from .options import EntranceRando from .options import EntranceRando
from worlds.generic import PlandoConnection from worlds.generic import PlandoConnection
from random import Random from random import Random
from copy import deepcopy
if TYPE_CHECKING: if TYPE_CHECKING:
from . import TunicWorld from . import TunicWorld
@@ -95,8 +95,7 @@ def place_event_items(world: "TunicWorld", regions: Dict[str, Region]) -> None:
def vanilla_portals() -> Dict[Portal, Portal]: def vanilla_portals() -> Dict[Portal, Portal]:
portal_pairs: Dict[Portal, Portal] = {} portal_pairs: Dict[Portal, Portal] = {}
# we don't want the zig skip exit for vanilla portals, since it shouldn't be considered for logic here portal_map = portal_mapping.copy()
portal_map = [portal for portal in portal_mapping if portal.name != "Ziggurat Lower Falling Entrance"]
while portal_map: while portal_map:
portal1 = portal_map[0] portal1 = portal_map[0]
@@ -131,13 +130,9 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
dead_ends: List[Portal] = [] dead_ends: List[Portal] = []
two_plus: List[Portal] = [] two_plus: List[Portal] = []
player_name = world.multiworld.get_player_name(world.player) player_name = world.multiworld.get_player_name(world.player)
portal_map = portal_mapping.copy()
logic_rules = world.options.logic_rules.value logic_rules = world.options.logic_rules.value
fixed_shop = world.options.fixed_shop fixed_shop = world.options.fixed_shop
laurels_location = world.options.laurels_location 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 it's not one of the EntranceRando options, it's a custom seed
if world.options.entrance_rando.value not in EntranceRando.options: if world.options.entrance_rando.value not in EntranceRando.options:
@@ -145,53 +140,38 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
logic_rules = seed_group["logic_rules"] logic_rules = seed_group["logic_rules"]
fixed_shop = seed_group["fixed_shop"] fixed_shop = seed_group["fixed_shop"]
laurels_location = "10_fairies" if seed_group["laurels_at_10_fairies"] is True else False 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_scenes: Set[str] = set()
shop_count = 6 shop_count = 6
if fixed_shop: if fixed_shop:
shop_count = 0 shop_count = 1
shop_scenes.add("Overworld Redux") shop_scenes.add("Overworld Redux")
if not logic_rules:
dependent_regions = dependent_regions_restricted
elif logic_rules == 1:
dependent_regions = dependent_regions_nmg
else: else:
# if fixed shop is off, remove this portal dependent_regions = dependent_regions_ur
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 # create separate lists for dead ends and non-dead ends
for portal in portal_map: if logic_rules:
dead_end_status = tunic_er_regions[portal.region].dead_end for portal in portal_mapping:
if dead_end_status == DeadEnd.free: if tunic_er_regions[portal.region].dead_end == 1:
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:
dead_ends.append(portal) dead_ends.append(portal)
# these two get special handling else:
elif dead_end_status == DeadEnd.special: two_plus.append(portal)
if portal.region == "Secret Gathering Place": else:
if laurels_location == "10_fairies": for portal in portal_mapping:
two_plus.append(portal) if tunic_er_regions[portal.region].dead_end:
else: dead_ends.append(portal)
dead_ends.append(portal) else:
if portal.region == "Zig Skip Exit": two_plus.append(portal)
if fixed_shop:
two_plus.append(portal)
else:
dead_ends.append(portal)
connected_regions: Set[str] = set() connected_regions: Set[str] = set()
# make better start region stuff when/if implementing random start # make better start region stuff when/if implementing random start
start_region = "Overworld" start_region = "Overworld"
connected_regions.add(start_region) connected_regions.update(add_dependent_regions(start_region, logic_rules))
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules)
if world.options.entrance_rando.value in EntranceRando.options: if world.options.entrance_rando.value in EntranceRando.options:
plando_connections = world.multiworld.plando_connections[world.player] plando_connections = world.multiworld.plando_connections[world.player]
@@ -225,17 +205,11 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
non_dead_end_regions.add(region_name) non_dead_end_regions.add(region_name)
elif region_info.dead_end == 2 and logic_rules: elif region_info.dead_end == 2 and logic_rules:
non_dead_end_regions.add(region_name) 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: if plando_connections:
for connection in plando_connections: for connection in plando_connections:
p_entrance = connection.entrance p_entrance = connection.entrance
p_exit = connection.exit p_exit = connection.exit
portal1_dead_end = True
portal2_dead_end = True
portal1 = None portal1 = None
portal2 = None portal2 = None
@@ -244,10 +218,8 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
for portal in two_plus: for portal in two_plus:
if p_entrance == portal.name: if p_entrance == portal.name:
portal1 = portal portal1 = portal
portal1_dead_end = False
if p_exit == portal.name: if p_exit == portal.name:
portal2 = portal portal2 = portal
portal2_dead_end = False
# search dead_ends individually since we can't really remove items from two_plus during the loop # search dead_ends individually since we can't really remove items from two_plus during the loop
if portal1: if portal1:
@@ -261,7 +233,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
else: else:
raise Exception(f"{player_name} paired a dead end to a dead end in their " raise Exception(f"{player_name} paired a dead end to a dead end in their "
"plando connections.") "plando connections.")
for portal in dead_ends: for portal in dead_ends:
if p_entrance == portal.name: if p_entrance == portal.name:
portal1 = portal portal1 = portal
@@ -274,6 +246,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
if portal2: if portal2:
two_plus.remove(portal2) two_plus.remove(portal2)
else: else:
# check if portal2 is a dead end
for portal in dead_ends: for portal in dead_ends:
if p_exit == portal.name: if p_exit == portal.name:
portal2 = portal portal2 = portal
@@ -283,7 +256,6 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
portal2 = Portal(name="Shop Portal", region="Shop", portal2 = Portal(name="Shop Portal", region="Shop",
destination="Previous Region", tag="_") destination="Previous Region", tag="_")
shop_count -= 1 shop_count -= 1
# need to maintain an even number of portals total
if shop_count < 0: if shop_count < 0:
shop_count += 2 shop_count += 2
for p in portal_mapping: for p in portal_mapping:
@@ -297,36 +269,48 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
f"plando connections in {player_name}'s YAML.") f"plando connections in {player_name}'s YAML.")
dead_ends.remove(portal2) 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 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 # if we have plando connections, our connected regions may change somewhat
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules) 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)
if fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"): if fixed_shop and not hasattr(world.multiworld, "re_gen_passthrough"):
portal1 = None portal1 = None
@@ -355,54 +339,47 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
previous_conn_num = 0 previous_conn_num = 0
fail_count = 0 fail_count = 0
while len(connected_regions) < len(non_dead_end_regions): while len(connected_regions) < len(non_dead_end_regions):
# 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 # 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 # should, hopefully, only ever occur if someone plandos connections poorly
if hasattr(world.multiworld, "re_gen_passthrough"):
break
if previous_conn_num == len(connected_regions): if previous_conn_num == len(connected_regions):
fail_count += 1 fail_count += 1
if fail_count >= 500: if fail_count >= 500:
raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for errors. " raise Exception(f"Failed to pair regions. Check plando connections for {player_name} for loops.")
"Unconnected regions:", non_dead_end_regions - connected_regions)
else: else:
fail_count = 0 fail_count = 0
previous_conn_num = len(connected_regions) previous_conn_num = len(connected_regions)
# find a portal in a connected region # find a portal in an inaccessible region
if check_success == 0: if check_success == 0:
for portal in two_plus: for portal in two_plus:
if portal.region in connected_regions: 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 portal1 = portal
two_plus.remove(portal) two_plus.remove(portal)
check_success = 1 check_success = 1
break break
# then we find a portal in an inaccessible region # then we find a portal in a connected region
if check_success == 1: if check_success == 1:
for portal in two_plus: for portal in two_plus:
if portal.region not in connected_regions: if portal.region not in connected_regions:
# if secret gathering place happens to get paired really late, you can end up running out # if there's risk of self-locking, shuffle and try again
if not has_laurels and len(two_plus) < 80: if gate_before_switch(portal, two_plus):
# if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this random_object.shuffle(two_plus)
if waterfall_plando: break
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 portal2 = portal
connected_regions.add(portal.region)
two_plus.remove(portal) two_plus.remove(portal)
check_success = 2 check_success = 2
break break
# once we have both portals, connect them and add the new region(s) to connected_regions # once we have both portals, connect them and add the new region(s) to connected_regions
if check_success == 2: if check_success == 2:
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules) connected_regions.update(add_dependent_regions(portal2.region, logic_rules))
if "Secret Gathering Place" in connected_regions:
has_laurels = True
portal_pairs[portal1] = portal2 portal_pairs[portal1] = portal2
check_success = 0 check_success = 0
random_object.shuffle(two_plus) random_object.shuffle(two_plus)
@@ -434,6 +411,7 @@ def pair_portals(world: "TunicWorld") -> Dict[Portal, Portal]:
portal1 = two_plus.pop() portal1 = two_plus.pop()
portal2 = dead_ends.pop() portal2 = dead_ends.pop()
portal_pairs[portal1] = portal2 portal_pairs[portal1] = portal2
# then randomly connect the remaining portals to each other # then randomly connect the remaining portals to each other
# every region is accessible, so gate_before_switch is not necessary # every region is accessible, so gate_before_switch is not necessary
while len(two_plus) > 1: while len(two_plus) > 1:
@@ -460,42 +438,126 @@ def create_randomized_entrances(portal_pairs: Dict[Portal, Portal], regions: Dic
region2.connect(connecting_region=region1, name=portal2.name) region2.connect(connecting_region=region1, name=portal2.name)
def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[str, Dict[str, List[List[str]]]], # loop through the static connections, return regions you can reach from this region
has_laurels: bool, logic: int) -> Set[str]: # todo: refactor to take region_name and dependent_regions
# starting count, so we can run it again if this changes def add_dependent_regions(region_name: str, logic_rules: int) -> Set[str]:
region_count = len(connected_regions) region_set = set()
for origin, destinations in traversal_reqs.items(): if not logic_rules:
if origin not in connected_regions: regions_to_add = dependent_regions_restricted
continue elif logic_rules == 1:
# check if we can traverse to any of the destinations regions_to_add = dependent_regions_nmg
for destination, req_lists in destinations.items(): else:
if destination in connected_regions: regions_to_add = dependent_regions_ur
continue for origin_regions, destination_regions in regions_to_add.items():
met_traversal_reqs = False if region_name in origin_regions:
if len(req_lists) == 0: # if you matched something in the first set, you get the regions in its paired set
met_traversal_reqs = True region_set.update(destination_regions)
# loop through each set of possible requirements, with a fancy for else loop return region_set
for reqs in req_lists: # if you didn't match anything in the first sets, just gives you the region
for req in reqs: region_set = {region_name}
if req == "Hyperdash": return region_set
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)
return connected_regions # 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

View File

@@ -237,8 +237,6 @@ 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 "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 Bell": {"Ladders to West Bell"},
"Ladders to Well": {"Ladders in Well"}, # fuzzy matching decided ladders in well was 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) 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"), "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 - 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 - Cell Chest 1": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"),
"Beneath the Fortress - Obscured Behind Waterfall": TunicLocationData("Beneath the Vault", "Beneath the Vault Main"), "Beneath the Fortress - Obscured Behind Waterfall": TunicLocationData("Beneath the Vault", "Beneath the Vault Front"),
"Beneath the Fortress - Back Room Chest": TunicLocationData("Beneath the Vault", "Beneath the Vault Back"), "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"), "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"), "Frog's Domain - Near Vault": TunicLocationData("Frog's Domain", "Frog's Domain"),

View File

@@ -118,8 +118,7 @@ class EntranceRando(TextChoice):
class FixedShop(Toggle): class FixedShop(Toggle):
"""Forces the Windmill entrance to lead to a shop, and removes the remaining shops from the pool. """Forces the Windmill entrance to lead to a shop, and places only one other shop in 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.""" Has no effect if Entrance Rando is not enabled."""
internal_name = "fixed_shop" internal_name = "fixed_shop"
display_name = "Fewer Shops in Entrance Rando" display_name = "Fewer Shops in Entrance Rando"
@@ -127,7 +126,8 @@ class FixedShop(Toggle):
class LaurelsLocation(Choice): class LaurelsLocation(Choice):
"""Force the Hero's Laurels to be placed at a location in your world. """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.""" 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."""
internal_name = "laurels_location" internal_name = "laurels_location"
display_name = "Laurels Location" display_name = "Laurels Location"
option_anywhere = 0 option_anywhere = 0
@@ -147,7 +147,6 @@ class ShuffleLadders(Toggle):
@dataclass @dataclass
class TunicOptions(PerGameCommonOptions): class TunicOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
sword_progression: SwordProgression sword_progression: SwordProgression
start_with_sword: StartWithSword start_with_sword: StartWithSword
keys_behind_bosses: KeysBehindBosses keys_behind_bosses: KeysBehindBosses
@@ -163,3 +162,4 @@ class TunicOptions(PerGameCommonOptions):
lanternless: Lanternless lanternless: Lanternless
maskless: Maskless maskless: Maskless
laurels_location: LaurelsLocation 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 .gen_data import GenData
from .logic import cs_to_zz_locs from .logic import cs_to_zz_locs
from .region import ZillionLocation, ZillionRegion from .region import ZillionLocation, ZillionRegion
from .options import ZillionOptions, validate, z_option_groups from .options import ZillionOptions, validate
from .id_maps import ZillionSlotInfo, get_slot_info, item_name_to_id as _item_name_to_id, \ 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, \ loc_name_to_id as _loc_name_to_id, make_id_to_others, \
zz_reg_name_to_reg_name, base_id zz_reg_name_to_reg_name, base_id
@@ -62,8 +62,6 @@ class ZillionWebWorld(WebWorld):
["beauxq"] ["beauxq"]
)] )]
option_groups = z_option_groups
class ZillionWorld(World): class ZillionWorld(World):
""" """

View File

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