mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-25 16:03:20 -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.logger.exception(e)
|
||||||
self._start_async_saving()
|
self._start_async_saving()
|
||||||
|
|
||||||
def _start_async_saving(self):
|
def _start_async_saving(self, atexit_save: bool = True):
|
||||||
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,8 +532,9 @@ 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()
|
||||||
|
|
||||||
import atexit
|
if atexit_save:
|
||||||
atexit.register(self._save, True) # make sure we save on exit too
|
import atexit
|
||||||
|
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()
|
||||||
|
|||||||
@@ -746,6 +746,7 @@ 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)
|
||||||
|
|||||||
10
WebHost.py
10
WebHost.py
@@ -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
|
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||||
from WebHostLib.options import create as create_options_files
|
from WebHostLib.options import create as create_options_files
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -138,3 +138,11 @@ 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
|
||||||
|
|||||||
@@ -3,16 +3,26 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import time
|
|
||||||
import typing
|
import typing
|
||||||
from uuid import UUID
|
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
|
from threading import Event, Thread
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
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}")
|
||||||
@@ -63,6 +73,7 @@ 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()
|
||||||
@@ -72,26 +83,25 @@ def autohost(config: dict):
|
|||||||
hosters.append(hoster)
|
hosters.append(hoster)
|
||||||
hoster.start()
|
hoster.start()
|
||||||
|
|
||||||
while 1:
|
while not stop_event.wait(0.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):
|
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
|
||||||
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.")
|
||||||
|
|
||||||
import threading
|
Thread(target=keep_running, name="AP_Autohost").start()
|
||||||
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"):
|
||||||
|
|
||||||
@@ -112,8 +122,7 @@ 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 1:
|
while not stop_event.wait(0.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(
|
||||||
@@ -124,8 +133,7 @@ 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.")
|
||||||
|
|
||||||
import threading
|
Thread(target=keep_running, name="AP_Autogen").start()
|
||||||
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
|
||||||
|
|
||||||
|
|
||||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ 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)
|
||||||
|
|
||||||
@@ -101,18 +102,37 @@ 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 self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||||
# non-custom. remove from multidata
|
# non-custom. remove from multidata and use static data
|
||||||
# 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
|
||||||
@@ -122,7 +142,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()
|
self._start_async_saving(atexit_save=False)
|
||||||
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
|
||||||
@@ -212,59 +232,62 @@ 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):
|
||||||
try:
|
with Locker(f"RoomLocker {room_id}"):
|
||||||
logger = set_up_logging(room_id)
|
|
||||||
ctx = WebHostContext(static_server_data, logger)
|
|
||||||
ctx.load(room_id)
|
|
||||||
ctx.init_save()
|
|
||||||
try:
|
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
|
await ctx.server
|
||||||
except OSError: # likely port in use
|
except OSError: # likely port in use
|
||||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
ctx.server = websockets.serve(
|
||||||
|
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]
|
||||||
elif wssocket.family == socket.AF_INET:
|
if port:
|
||||||
port = socketname[1]
|
ctx.logger.info(f'Hosting game at {host}:{port}')
|
||||||
if port:
|
with db_session:
|
||||||
ctx.logger.info(f'Hosting game at {host}:{port}')
|
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:
|
with db_session:
|
||||||
room = Room.get(id=ctx.room_id)
|
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||||
room.last_port = port
|
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||||
else:
|
await ctx.shutdown_task
|
||||||
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
|
|
||||||
|
|
||||||
# ensure auto launch is on the same page in regard to room activity.
|
except (KeyboardInterrupt, SystemExit):
|
||||||
with db_session:
|
pass
|
||||||
room: Room = Room.get(id=ctx.room_id)
|
except Exception:
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
|
with db_session:
|
||||||
|
room = Room.get(id=room_id)
|
||||||
except (KeyboardInterrupt, SystemExit):
|
room.last_port = -1
|
||||||
with db_session:
|
raise
|
||||||
room = Room.get(id=room_id)
|
finally:
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
try:
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
ctx._save()
|
||||||
except Exception:
|
with (db_session):
|
||||||
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 = Room.get(id=room_id)
|
||||||
room.last_port = -1
|
room.last_activity = datetime.datetime.utcnow() - \
|
||||||
# ensure the Room does not spin up again on its own, minute of safety buffer
|
datetime.timedelta(minutes=1, seconds=room.timeout)
|
||||||
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
|
logging.info(f"Shutting down room {room_id} on {name}.")
|
||||||
raise
|
finally:
|
||||||
finally:
|
await asyncio.sleep(5)
|
||||||
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):
|
||||||
|
|||||||
@@ -70,37 +70,41 @@ def generate(race=False):
|
|||||||
flash(options)
|
flash(options)
|
||||||
else:
|
else:
|
||||||
meta = get_meta(request.form, race)
|
meta = get_meta(request.form, race)
|
||||||
results, gen_options = roll_options(options, set(meta["plando_options"]))
|
return start_generation(options, meta)
|
||||||
|
|
||||||
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] = {}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import collections.abc
|
import collections.abc
|
||||||
import os
|
|
||||||
import yaml
|
|
||||||
import requests
|
|
||||||
import json
|
import json
|
||||||
import flask
|
import os
|
||||||
|
from textwrap import dedent
|
||||||
|
from typing import Dict, Union
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from flask import redirect, render_template, request, Response
|
||||||
|
|
||||||
import Options
|
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 Utils import local_path
|
||||||
from textwrap import dedent
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import app, cache
|
from . import app, cache
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create() -> None:
|
||||||
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):
|
def get_world_theme(game_name: str) -> 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):
|
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]
|
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,13 +39,8 @@ 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 not is_complex and option.visibility < Visibility.simple_ui:
|
if visibility_flag in option.visibility:
|
||||||
continue
|
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
|
||||||
|
|
||||||
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,
|
||||||
@@ -58,26 +53,12 @@ def render_options_page(template: str, world_name: str, is_complex: bool = False
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_game(player_name: str, formatted_options: dict):
|
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
|
||||||
payload = {
|
from .generate import start_generation
|
||||||
"race": 0,
|
return start_generation(options, {"plando_options": ["items", "connections", "texts", "bosses"]})
|
||||||
"hint_cost": 10,
|
|
||||||
"forfeit_mode": "auto",
|
|
||||||
"remaining_mode": "disabled",
|
|
||||||
"collect_mode": "goal",
|
|
||||||
"weights": {
|
|
||||||
player_name: formatted_options,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
r = requests.post("https://archipelago.gg/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):
|
def send_yaml(player_name: str, formatted_options: dict) -> Response:
|
||||||
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"
|
||||||
@@ -85,7 +66,7 @@ def send_yaml(player_name: str, formatted_options: dict):
|
|||||||
|
|
||||||
|
|
||||||
@app.template_filter("dedent")
|
@app.template_filter("dedent")
|
||||||
def filter_dedent(text: str):
|
def filter_dedent(text: str) -> str:
|
||||||
return dedent(text).strip("\n ")
|
return dedent(text).strip("\n ")
|
||||||
|
|
||||||
|
|
||||||
@@ -98,10 +79,6 @@ 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):
|
||||||
@@ -110,8 +87,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(presets, cls=SetEncoder)
|
json_data = json.dumps(world.web.options_presets, cls=SetEncoder)
|
||||||
response = flask.Response(json_data)
|
response = Response(json_data)
|
||||||
response.headers["Content-Type"] = "application/json"
|
response.headers["Content-Type"] = "application/json"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -169,7 +146,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)
|
||||||
@@ -243,7 +220,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)
|
||||||
|
|||||||
@@ -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 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">
|
<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"> </div>
|
<div class="option-divider"> </div>
|
||||||
{% endif %}
|
{% 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">
|
<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"> </div>
|
<div class="option-divider"> </div>
|
||||||
{% endif %}
|
{% 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">
|
<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>
|
||||||
|
|||||||
@@ -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 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">
|
<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"> </div>
|
<div class="divider"> </div>
|
||||||
{% endif %}
|
{% 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">
|
<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"> </div>
|
<div class="set-divider"> </div>
|
||||||
{% endif %}
|
{% 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">
|
<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>
|
||||||
|
|||||||
@@ -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)
|
||||||
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 - 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 - 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))
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
factorio-rcon-py>=2.1.1; python_version >= '3.9'
|
factorio-rcon-py>=2.1.2
|
||||||
factorio-rcon-py==2.0.1; python_version <= '3.8'
|
|
||||||
|
|||||||
@@ -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, RoomAndDoor, RoomAndPanel
|
from .datatypes import Door, DoorType, Painting, 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,13 +361,29 @@ 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()
|
|
||||||
if not painting.exit_only and not painting.disable and not painting.req_blocked and
|
def is_req_enterable(painting_id: str, painting: Painting) -> bool:
|
||||||
not painting.req_blocked_when_no_doors and painting.room not in required_painting_rooms]
|
if painting.exit_only or painting.disable or painting.req_blocked\
|
||||||
else:
|
or painting.room in required_painting_rooms:
|
||||||
req_enterable = [painting_id for painting_id, painting in PAINTINGS.items()
|
return False
|
||||||
if not painting.exit_only and not painting.disable and not painting.req_blocked and
|
|
||||||
painting.room not in required_painting_rooms]
|
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()
|
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))
|
||||||
|
|||||||
@@ -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 = 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]}
|
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
@@ -268,7 +268,8 @@ 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(
|
||||||
@@ -706,17 +707,18 @@ 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 Front"],
|
connecting_region=regions["Beneath the Vault Main"],
|
||||||
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 Front"].connect(
|
and has_lantern(state, player, options))
|
||||||
|
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 Front"].connect(
|
regions["Beneath the Vault Main"].connect(
|
||||||
connecting_region=regions["Beneath the Vault Back"],
|
connecting_region=regions["Beneath the Vault Back"])
|
||||||
rule=lambda state: has_lantern(state, player, options))
|
|
||||||
regions["Beneath the Vault Back"].connect(
|
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(
|
regions["Fortress East Shortcut Upper"].connect(
|
||||||
connecting_region=regions["Fortress East Shortcut Lower"])
|
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(
|
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))
|
||||||
@@ -1453,8 +1458,6 @@ 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),
|
||||||
|
|||||||
@@ -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, \
|
from .er_data import Portal, tunic_er_regions, portal_mapping, traversal_requirements, DeadEnd
|
||||||
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,7 +95,8 @@ 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] = {}
|
||||||
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:
|
while portal_map:
|
||||||
portal1 = portal_map[0]
|
portal1 = portal_map[0]
|
||||||
@@ -130,9 +131,13 @@ 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:
|
||||||
@@ -140,38 +145,53 @@ 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 = 1
|
shop_count = 0
|
||||||
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:
|
||||||
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
|
# create separate lists for dead ends and non-dead ends
|
||||||
if logic_rules:
|
for portal in portal_map:
|
||||||
for portal in portal_mapping:
|
dead_end_status = tunic_er_regions[portal.region].dead_end
|
||||||
if tunic_er_regions[portal.region].dead_end == 1:
|
if dead_end_status == DeadEnd.free:
|
||||||
dead_ends.append(portal)
|
two_plus.append(portal)
|
||||||
else:
|
elif dead_end_status == DeadEnd.all_cats:
|
||||||
|
dead_ends.append(portal)
|
||||||
|
elif dead_end_status == DeadEnd.restricted:
|
||||||
|
if logic_rules:
|
||||||
two_plus.append(portal)
|
two_plus.append(portal)
|
||||||
else:
|
|
||||||
for portal in portal_mapping:
|
|
||||||
if tunic_er_regions[portal.region].dead_end:
|
|
||||||
dead_ends.append(portal)
|
|
||||||
else:
|
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()
|
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.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:
|
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]
|
||||||
@@ -205,11 +225,17 @@ 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
|
||||||
@@ -218,8 +244,10 @@ 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:
|
||||||
@@ -233,7 +261,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
|
||||||
@@ -246,7 +274,6 @@ 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
|
||||||
@@ -256,6 +283,7 @@ 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:
|
||||||
@@ -269,48 +297,36 @@ 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
|
||||||
while True:
|
connected_regions = update_reachable_regions(connected_regions, traversal_reqs, has_laurels, logic_rules)
|
||||||
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
|
||||||
@@ -339,47 +355,54 @@ 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 the connected regions length stays unchanged for too long, it's stuck in a loop
|
# if this is universal tracker, just break immediately and move on
|
||||||
# should, hopefully, only ever occur if someone plandos connections poorly
|
|
||||||
if hasattr(world.multiworld, "re_gen_passthrough"):
|
if hasattr(world.multiworld, "re_gen_passthrough"):
|
||||||
break
|
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):
|
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 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:
|
else:
|
||||||
fail_count = 0
|
fail_count = 0
|
||||||
previous_conn_num = len(connected_regions)
|
previous_conn_num = len(connected_regions)
|
||||||
|
|
||||||
# find a portal in an inaccessible region
|
# find a portal in a connected 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 a connected region
|
# then we find a portal in an inaccessible 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 there's risk of self-locking, shuffle and try again
|
# if secret gathering place happens to get paired really late, you can end up running out
|
||||||
if gate_before_switch(portal, two_plus):
|
if not has_laurels and len(two_plus) < 80:
|
||||||
random_object.shuffle(two_plus)
|
# if you plando'd secret gathering place with laurels at 10 fairies, you're the reason for this
|
||||||
break
|
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
|
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(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
|
portal_pairs[portal1] = portal2
|
||||||
check_success = 0
|
check_success = 0
|
||||||
random_object.shuffle(two_plus)
|
random_object.shuffle(two_plus)
|
||||||
@@ -411,7 +434,6 @@ 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:
|
||||||
@@ -438,126 +460,42 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
# loop through the static connections, return regions you can reach from this region
|
def update_reachable_regions(connected_regions: Set[str], traversal_reqs: Dict[str, Dict[str, List[List[str]]]],
|
||||||
# todo: refactor to take region_name and dependent_regions
|
has_laurels: bool, logic: int) -> Set[str]:
|
||||||
def add_dependent_regions(region_name: str, logic_rules: int) -> Set[str]:
|
# starting count, so we can run it again if this changes
|
||||||
region_set = set()
|
region_count = len(connected_regions)
|
||||||
if not logic_rules:
|
for origin, destinations in traversal_reqs.items():
|
||||||
regions_to_add = dependent_regions_restricted
|
if origin not in connected_regions:
|
||||||
elif logic_rules == 1:
|
continue
|
||||||
regions_to_add = dependent_regions_nmg
|
# check if we can traverse to any of the destinations
|
||||||
else:
|
for destination, req_lists in destinations.items():
|
||||||
regions_to_add = dependent_regions_ur
|
if destination in connected_regions:
|
||||||
for origin_regions, destination_regions in regions_to_add.items():
|
continue
|
||||||
if region_name in origin_regions:
|
met_traversal_reqs = False
|
||||||
# if you matched something in the first set, you get the regions in its paired set
|
if len(req_lists) == 0:
|
||||||
region_set.update(destination_regions)
|
met_traversal_reqs = True
|
||||||
return region_set
|
# loop through each set of possible requirements, with a fancy for else loop
|
||||||
# if you didn't match anything in the first sets, just gives you the region
|
for reqs in req_lists:
|
||||||
region_set = {region_name}
|
for req in reqs:
|
||||||
return region_set
|
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
|
return connected_regions
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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
|
"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)
|
||||||
|
|||||||
@@ -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 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 - 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"),
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ class EntranceRando(TextChoice):
|
|||||||
|
|
||||||
|
|
||||||
class FixedShop(Toggle):
|
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."""
|
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"
|
||||||
@@ -126,8 +127,7 @@ 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,6 +147,7 @@ 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
|
||||||
@@ -162,4 +163,3 @@ class TunicOptions(PerGameCommonOptions):
|
|||||||
lanternless: Lanternless
|
lanternless: Lanternless
|
||||||
maskless: Maskless
|
maskless: Maskless
|
||||||
laurels_location: LaurelsLocation
|
laurels_location: LaurelsLocation
|
||||||
start_inventory_from_pool: StartInventoryPool
|
|
||||||
|
|||||||
@@ -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
|
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, \
|
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,6 +62,8 @@ class ZillionWebWorld(WebWorld):
|
|||||||
["beauxq"]
|
["beauxq"]
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
option_groups = z_option_groups
|
||||||
|
|
||||||
|
|
||||||
class ZillionWorld(World):
|
class ZillionWorld(World):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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 DefaultOnToggle, NamedRange, PerGameCommonOptions, Range, Toggle, Choice
|
from Options import Choice, DefaultOnToggle, NamedRange, OptionGroup, PerGameCommonOptions, Range, Toggle
|
||||||
|
|
||||||
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,6 +279,14 @@ 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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user