mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-11 10:03:44 -07:00
Compare commits
15 Commits
api-refere
...
options-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a169649500 | ||
|
|
5910b94deb | ||
|
|
14ffd1c70c | ||
|
|
754fc11c1b | ||
|
|
12cde88f95 | ||
|
|
e0b6889634 | ||
|
|
14321d6ba2 | ||
|
|
e978109410 | ||
|
|
019dfb8242 | ||
|
|
8e9a050889 | ||
|
|
2801e21296 | ||
|
|
e97eddcdaf | ||
|
|
d3f4ee4994 | ||
|
|
cf34f125d6 | ||
|
|
663b50b33e |
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
WebHost.py
10
WebHost.py
@@ -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
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"> </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"> </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>
|
||||
|
||||
@@ -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"> </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"> </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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user