mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 02:43:20 -07:00
Compare commits
46 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bb0f13a0 | ||
|
|
cbb1535242 | ||
|
|
643f61e7f4 | ||
|
|
6b91ffecf1 | ||
|
|
4f7f092b9b | ||
|
|
df3c6b7980 | ||
|
|
19839399e5 | ||
|
|
4847be98d2 | ||
|
|
3105320038 | ||
|
|
e8c8b0dbc5 | ||
|
|
c199775c48 | ||
|
|
d2bf7fdaf7 | ||
|
|
621ec274c3 | ||
|
|
7cd73e2710 | ||
|
|
708df4d1e2 | ||
|
|
914a534a3b | ||
|
|
11d18db452 | ||
|
|
00acfe63d4 | ||
|
|
2ac9ab5337 | ||
|
|
2569c9e531 | ||
|
|
946f227226 | ||
|
|
7ead8fdf49 | ||
|
|
f5f554cb3d | ||
|
|
3f2942c599 | ||
|
|
da519e7f73 | ||
|
|
0718ada682 | ||
|
|
f756919dd9 | ||
|
|
406b905dc8 | ||
|
|
91439e0fb0 | ||
|
|
03bd59bff6 | ||
|
|
cf02e1a1aa | ||
|
|
f6d696ea62 | ||
|
|
123acdef23 | ||
|
|
28c7a214dc | ||
|
|
bdae7cd42c | ||
|
|
fc404d0cf7 | ||
|
|
5ce71db048 | ||
|
|
aff98a5b78 | ||
|
|
30cedb13f3 | ||
|
|
0c1ecf7297 | ||
|
|
5390561b58 | ||
|
|
bb457b0f73 | ||
|
|
6276ccf415 | ||
|
|
d3588a057c | ||
|
|
30ce74d6d5 | ||
|
|
ff59b86335 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -9,12 +9,14 @@ on:
|
|||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
|
- 'worlds/*/archipelago.json'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build.yml'
|
- '.github/workflows/build.yml'
|
||||||
- 'setup.py'
|
- 'setup.py'
|
||||||
- 'requirements.txt'
|
- 'requirements.txt'
|
||||||
- '*.iss'
|
- '*.iss'
|
||||||
|
- 'worlds/*/archipelago.json'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -856,9 +856,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
|||||||
|
|
||||||
server_url = urllib.parse.urlparse(address)
|
server_url = urllib.parse.urlparse(address)
|
||||||
if server_url.username:
|
if server_url.username:
|
||||||
ctx.username = server_url.username
|
ctx.username = urllib.parse.unquote(server_url.username)
|
||||||
if server_url.password:
|
if server_url.password:
|
||||||
ctx.password = server_url.password
|
ctx.password = urllib.parse.unquote(server_url.password)
|
||||||
|
|
||||||
def reconnect_hint() -> str:
|
def reconnect_hint() -> str:
|
||||||
return ", type /connect to reconnect" if ctx.server_address else ""
|
return ", type /connect to reconnect" if ctx.server_address else ""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
|||||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
|
||||||
|
|
||||||
|
|
||||||
def mystery_argparse():
|
def mystery_argparse(argv: list[str] | None = None):
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
defaults = settings.generator
|
defaults = settings.generator
|
||||||
@@ -57,7 +57,7 @@ def mystery_argparse():
|
|||||||
parser.add_argument("--spoiler_only", action="store_true",
|
parser.add_argument("--spoiler_only", action="store_true",
|
||||||
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||||
"Intended for debugging and testing purposes.")
|
"Intended for debugging and testing purposes.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.skip_output and args.spoiler_only:
|
if args.skip_output and args.spoiler_only:
|
||||||
parser.error("Cannot mix --skip_output and --spoiler_only")
|
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
|||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
|
"__weakref__",
|
||||||
"version",
|
"version",
|
||||||
"auth",
|
"auth",
|
||||||
"team",
|
"team",
|
||||||
@@ -216,6 +217,7 @@ class Context:
|
|||||||
"release_mode": str,
|
"release_mode": str,
|
||||||
"remaining_mode": str,
|
"remaining_mode": str,
|
||||||
"collect_mode": str,
|
"collect_mode": str,
|
||||||
|
"countdown_mode": str,
|
||||||
"item_cheat": bool,
|
"item_cheat": bool,
|
||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
@@ -245,8 +247,8 @@ class Context:
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
|
||||||
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
super(Context, self).__init__()
|
super(Context, self).__init__()
|
||||||
self.slot_info = {}
|
self.slot_info = {}
|
||||||
@@ -279,6 +281,7 @@ class Context:
|
|||||||
self.release_mode: str = release_mode
|
self.release_mode: str = release_mode
|
||||||
self.remaining_mode: str = remaining_mode
|
self.remaining_mode: str = remaining_mode
|
||||||
self.collect_mode: str = collect_mode
|
self.collect_mode: str = collect_mode
|
||||||
|
self.countdown_mode: str = countdown_mode
|
||||||
self.item_cheat = item_cheat
|
self.item_cheat = item_cheat
|
||||||
self.exit_event = asyncio.Event()
|
self.exit_event = asyncio.Event()
|
||||||
self.client_activity_timers: typing.Dict[
|
self.client_activity_timers: typing.Dict[
|
||||||
@@ -664,6 +667,7 @@ class Context:
|
|||||||
"server_password": self.server_password, "password": self.password,
|
"server_password": self.server_password, "password": self.password,
|
||||||
"release_mode": self.release_mode,
|
"release_mode": self.release_mode,
|
||||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||||
|
"countdown_mode": self.countdown_mode,
|
||||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -698,6 +702,7 @@ class Context:
|
|||||||
self.release_mode = savedata["game_options"]["release_mode"]
|
self.release_mode = savedata["game_options"]["release_mode"]
|
||||||
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
||||||
self.collect_mode = savedata["game_options"]["collect_mode"]
|
self.collect_mode = savedata["game_options"]["collect_mode"]
|
||||||
|
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
|
||||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||||
self.compatibility = savedata["game_options"]["compatibility"]
|
self.compatibility = savedata["game_options"]["compatibility"]
|
||||||
|
|
||||||
@@ -1195,16 +1200,17 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
found = location_id in ctx.location_checks[team, finding_player]
|
found = location_id in ctx.location_checks[team, finding_player]
|
||||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||||
|
|
||||||
|
hint_status = status # Assign again because we're in a for loop
|
||||||
if found:
|
if found:
|
||||||
status = HintStatus.HINT_FOUND
|
hint_status = HintStatus.HINT_FOUND
|
||||||
elif status is None:
|
elif hint_status is None:
|
||||||
if item_flags & ItemClassification.trap:
|
if item_flags & ItemClassification.trap:
|
||||||
status = HintStatus.HINT_AVOID
|
hint_status = HintStatus.HINT_AVOID
|
||||||
else:
|
else:
|
||||||
status = HintStatus.HINT_PRIORITY
|
hint_status = HintStatus.HINT_PRIORITY
|
||||||
|
|
||||||
hints.append(
|
hints.append(
|
||||||
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
|
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
|
||||||
)
|
)
|
||||||
|
|
||||||
return hints
|
return hints
|
||||||
@@ -1529,6 +1535,23 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
|||||||
" You can ask the server admin for a /collect")
|
" You can ask the server admin for a /collect")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _cmd_countdown(self, seconds: str = "10") -> bool:
|
||||||
|
"""Start a countdown in seconds"""
|
||||||
|
if self.ctx.countdown_mode == "disabled" or \
|
||||||
|
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
|
||||||
|
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
timer = int(seconds, 10)
|
||||||
|
except ValueError:
|
||||||
|
timer = 10
|
||||||
|
else:
|
||||||
|
if timer > 60 * 60:
|
||||||
|
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
|
||||||
|
|
||||||
|
async_start(countdown(self.ctx, timer))
|
||||||
|
return True
|
||||||
|
|
||||||
def _cmd_remaining(self) -> bool:
|
def _cmd_remaining(self) -> bool:
|
||||||
"""List remaining items in your game, but not their location or recipient"""
|
"""List remaining items in your game, but not their location or recipient"""
|
||||||
if self.ctx.remaining_mode == "enabled":
|
if self.ctx.remaining_mode == "enabled":
|
||||||
@@ -2489,6 +2512,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
|||||||
elif value_type == str and option_name.endswith("password"):
|
elif value_type == str and option_name.endswith("password"):
|
||||||
def value_type(input_text: str):
|
def value_type(input_text: str):
|
||||||
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
|
||||||
|
elif option_name == "countdown_mode":
|
||||||
|
valid_values = {"enabled", "disabled", "auto"}
|
||||||
|
if option_value.lower() not in valid_values:
|
||||||
|
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
|
||||||
|
return False
|
||||||
elif value_type == str and option_name.endswith("mode"):
|
elif value_type == str and option_name.endswith("mode"):
|
||||||
valid_values = {"goal", "enabled", "disabled"}
|
valid_values = {"goal", "enabled", "disabled"}
|
||||||
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
|
||||||
@@ -2576,6 +2604,13 @@ def parse_args() -> argparse.Namespace:
|
|||||||
goal: !collect can be used after goal completion
|
goal: !collect can be used after goal completion
|
||||||
auto-enabled: !collect is available and automatically triggered on goal completion
|
auto-enabled: !collect is available and automatically triggered on goal completion
|
||||||
''')
|
''')
|
||||||
|
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
|
||||||
|
choices=['enabled', 'disabled', "auto"], help='''\
|
||||||
|
Select !countdown Accessibility. (default: %(default)s)
|
||||||
|
enabled: !countdown is always available
|
||||||
|
disabled: !countdown is never available
|
||||||
|
auto: !countdown is available for rooms with less than 30 players
|
||||||
|
''')
|
||||||
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
|
||||||
choices=['enabled', 'disabled', "goal"], help='''\
|
choices=['enabled', 'disabled', "goal"], help='''\
|
||||||
Select !remaining Accessibility. (default: %(default)s)
|
Select !remaining Accessibility. (default: %(default)s)
|
||||||
@@ -2641,7 +2676,7 @@ async def main(args: argparse.Namespace):
|
|||||||
|
|
||||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||||
args.remaining_mode,
|
args.countdown_mode, args.remaining_mode,
|
||||||
args.auto_shutdown, args.compatibility, args.log_network)
|
args.auto_shutdown, args.compatibility, args.log_network)
|
||||||
data_filename = args.multidata
|
data_filename = args.multidata
|
||||||
|
|
||||||
|
|||||||
@@ -1474,8 +1474,10 @@ class ItemLinks(OptionList):
|
|||||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||||
existing_links = set()
|
existing_links = set()
|
||||||
for link in self.value:
|
for link in self.value:
|
||||||
|
link["name"] = link["name"].strip()[:16].strip()
|
||||||
if link["name"] in existing_links:
|
if link["name"] in existing_links:
|
||||||
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
|
||||||
|
f"You have more than one link named '{link['name']}'.")
|
||||||
existing_links.add(link["name"])
|
existing_links.add(link["name"])
|
||||||
|
|
||||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from json import loads, dumps
|
|||||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
from settings import Settings
|
import settings
|
||||||
from Utils import async_start
|
from Utils import async_start
|
||||||
from MultiServer import mark_raw
|
from MultiServer import mark_raw
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
@@ -286,7 +286,7 @@ class SNESState(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
def launch_sni() -> None:
|
def launch_sni() -> None:
|
||||||
sni_path = Settings.sni_options.sni_path
|
sni_path = settings.get_settings().sni_options.sni_path
|
||||||
|
|
||||||
if not os.path.isdir(sni_path):
|
if not os.path.isdir(sni_path):
|
||||||
sni_path = Utils.local_path(sni_path)
|
sni_path = Utils.local_path(sni_path)
|
||||||
@@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_game(romfile: str) -> None:
|
async def run_game(romfile: str) -> None:
|
||||||
auto_start = Settings.sni_options.snes_rom_start
|
auto_start = settings.get_settings().sni_options.snes_rom_start
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(romfile)
|
webbrowser.open(romfile)
|
||||||
|
|||||||
40
Utils.py
40
Utils.py
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import typing
|
import typing
|
||||||
import builtins
|
import builtins
|
||||||
@@ -477,7 +478,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
mod = importlib.import_module(module)
|
mod = importlib.import_module(module)
|
||||||
obj = getattr(mod, name)
|
obj = getattr(mod, name)
|
||||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||||
self.options_module.PlandoText)):
|
self.options_module.PlandoItem, self.options_module.PlandoText)):
|
||||||
return obj
|
return obj
|
||||||
# Forbid everything else.
|
# Forbid everything else.
|
||||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||||
@@ -1138,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
|||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
return False
|
return False
|
||||||
return isinstance(obj, typing.Iterable)
|
return isinstance(obj, typing.Iterable)
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||||
|
"""
|
||||||
|
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
||||||
|
NOTE: use this with caution because killed threads will not properly clean up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _adjust_thread_count(self):
|
||||||
|
# see upstream ThreadPoolExecutor for details
|
||||||
|
import threading
|
||||||
|
import weakref
|
||||||
|
from concurrent.futures.thread import _worker
|
||||||
|
|
||||||
|
if self._idle_semaphore.acquire(timeout=0):
|
||||||
|
return
|
||||||
|
|
||||||
|
def weakref_cb(_, q=self._work_queue):
|
||||||
|
q.put(None)
|
||||||
|
|
||||||
|
num_threads = len(self._threads)
|
||||||
|
if num_threads < self._max_workers:
|
||||||
|
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
|
||||||
|
t = threading.Thread(
|
||||||
|
name=thread_name,
|
||||||
|
target=_worker,
|
||||||
|
args=(
|
||||||
|
weakref.ref(self, weakref_cb),
|
||||||
|
self._work_queue,
|
||||||
|
self._initializer,
|
||||||
|
self._initargs,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
self._threads.add(t)
|
||||||
|
# NOTE: don't add to _threads_queues so we don't block on shutdown
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -61,20 +62,21 @@ cache = Cache()
|
|||||||
Compress(app)
|
Compress(app)
|
||||||
|
|
||||||
|
|
||||||
def to_python(value):
|
def to_python(value: str) -> uuid.UUID:
|
||||||
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
|
||||||
|
|
||||||
|
|
||||||
def to_url(value):
|
def to_url(value: uuid.UUID) -> str:
|
||||||
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
class B64UUIDConverter(BaseConverter):
|
class B64UUIDConverter(BaseConverter):
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value: str) -> uuid.UUID:
|
||||||
return to_python(value)
|
return to_python(value)
|
||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value: typing.Any) -> str:
|
||||||
|
assert isinstance(value, uuid.UUID)
|
||||||
return to_url(value)
|
return to_url(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ app.jinja_env.filters["suuid"] = to_url
|
|||||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register() -> None:
|
||||||
"""Import submodules, triggering their registering on flask routing.
|
"""Import submodules, triggering their registering on flask routing.
|
||||||
Note: initializes worlds subsystem."""
|
Note: initializes worlds subsystem."""
|
||||||
import importlib
|
import importlib
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
|
|||||||
_stop_event = Event()
|
_stop_event = Event()
|
||||||
|
|
||||||
|
|
||||||
def stop():
|
def stop() -> None:
|
||||||
"""Stops previously launched threads"""
|
"""Stops previously launched threads"""
|
||||||
global _stop_event
|
global _stop_event
|
||||||
stop_event = _stop_event
|
stop_event = _stop_event
|
||||||
@@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException):
|
|||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
|
|
||||||
|
|
||||||
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
def _mp_gen_game(
|
||||||
|
gen_options: dict,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
owner=None,
|
||||||
|
sid=None,
|
||||||
|
timeout: int|None = None,
|
||||||
|
) -> PrimaryKey | None:
|
||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
|
|
||||||
setproctitle(f"Generator ({sid})")
|
setproctitle(f"Generator ({sid})")
|
||||||
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
try:
|
||||||
setproctitle(f"Generator (idle)")
|
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
||||||
return res
|
finally:
|
||||||
|
setproctitle(f"Generator (idle)")
|
||||||
|
|
||||||
|
|
||||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
|
||||||
try:
|
try:
|
||||||
meta = json.loads(generation.meta)
|
meta = json.loads(generation.meta)
|
||||||
options = restricted_loads(generation.options)
|
options = restricted_loads(generation.options)
|
||||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||||
pool.apply_async(_mp_gen_game, (options,),
|
pool.apply_async(
|
||||||
{"meta": meta,
|
_mp_gen_game,
|
||||||
"sid": generation.id,
|
(options,),
|
||||||
"owner": generation.owner},
|
{
|
||||||
handle_generation_success, handle_generation_failure)
|
"meta": meta,
|
||||||
|
"sid": generation.id,
|
||||||
|
"owner": generation.owner,
|
||||||
|
"timeout": timeout,
|
||||||
|
},
|
||||||
|
handle_generation_success,
|
||||||
|
handle_generation_failure,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
generation.state = STATE_ERROR
|
generation.state = STATE_ERROR
|
||||||
commit()
|
commit()
|
||||||
@@ -135,6 +149,7 @@ def autogen(config: dict):
|
|||||||
|
|
||||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||||
|
job_time = config["JOB_TIME"]
|
||||||
with db_session:
|
with db_session:
|
||||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||||
|
|
||||||
@@ -145,7 +160,7 @@ def autogen(config: dict):
|
|||||||
if sid:
|
if sid:
|
||||||
generation.delete()
|
generation.delete()
|
||||||
else:
|
else:
|
||||||
launch_generator(generator_pool, generation)
|
launch_generator(generator_pool, generation, timeout=job_time)
|
||||||
|
|
||||||
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()
|
||||||
@@ -157,7 +172,7 @@ def autogen(config: dict):
|
|||||||
generation for generation in Generation
|
generation for generation in Generation
|
||||||
if generation.state == STATE_QUEUED).for_update()
|
if generation.state == STATE_QUEUED).for_update()
|
||||||
for generation in to_start:
|
for generation in to_start:
|
||||||
launch_generator(generator_pool, generation)
|
launch_generator(generator_pool, generation, timeout=job_time)
|
||||||
except AlreadyRunningException:
|
except AlreadyRunningException:
|
||||||
logging.info("Autogen reports as already running, not starting another.")
|
logging.info("Autogen reports as already running, not starting another.")
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
|
|||||||
from BaseClasses import get_seed, seeddigits
|
from BaseClasses import get_seed, seeddigits
|
||||||
from Generate import PlandoOptions, handle_name, mystery_argparse
|
from Generate import PlandoOptions, handle_name, mystery_argparse
|
||||||
from Main import main as ERmain
|
from Main import main as ERmain
|
||||||
from Utils import __version__, restricted_dumps
|
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
||||||
from WebHostLib import app
|
from WebHostLib import app
|
||||||
from settings import ServerOptions, GeneratorOptions
|
from settings import ServerOptions, GeneratorOptions
|
||||||
from .check import get_yaml_data, roll_options
|
from .check import get_yaml_data, roll_options
|
||||||
@@ -33,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
|
|||||||
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
|
||||||
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
|
||||||
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
|
||||||
|
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
|
||||||
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
|
||||||
"server_password": str(options_source.get("server_password", None)),
|
"server_password": str(options_source.get("server_password", None)),
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||||
meta=meta, owner=session["_id"].int)
|
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
from .autolauncher import handle_generation_failure
|
from .autolauncher import handle_generation_failure
|
||||||
handle_generation_failure(e)
|
handle_generation_failure(e)
|
||||||
@@ -117,7 +118,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
|||||||
return redirect(url_for("view_seed", seed=seed_id))
|
return redirect(url_for("view_seed", seed=seed_id))
|
||||||
|
|
||||||
|
|
||||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
|
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
|
||||||
if meta is None:
|
if meta is None:
|
||||||
meta = {}
|
meta = {}
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
|
|
||||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||||
|
|
||||||
args = mystery_argparse()
|
args = mystery_argparse([]) # Just to set up the Namespace with defaults
|
||||||
args.multi = playercount
|
args.multi = playercount
|
||||||
args.seed = seed
|
args.seed = seed
|
||||||
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||||
@@ -171,11 +172,12 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
ERmain(args, seed, baked_server_options=meta["server_options"])
|
ERmain(args, seed, baked_server_options=meta["server_options"])
|
||||||
|
|
||||||
return upload_to_db(target.name, sid, owner, race)
|
return upload_to_db(target.name, sid, owner, race)
|
||||||
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
||||||
|
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
|
||||||
thread = thread_pool.submit(task)
|
thread = thread_pool.submit(task)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return thread.result(app.config["JOB_TIME"])
|
return thread.result(timeout)
|
||||||
except concurrent.futures.TimeoutError as e:
|
except concurrent.futures.TimeoutError as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -188,6 +190,9 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
format_exception(e))
|
format_exception(e))
|
||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
# don't update db, retry next time
|
||||||
|
raise
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
if sid:
|
if sid:
|
||||||
with db_session:
|
with db_session:
|
||||||
@@ -199,6 +204,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
|||||||
gen.meta = json.dumps(meta)
|
gen.meta = json.dumps(meta)
|
||||||
commit()
|
commit()
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
# free resources claimed by thread pool, if possible
|
||||||
|
# NOTE: Timeout depends on the process being killed at some point
|
||||||
|
# since we can't actually cancel a running gen at the moment.
|
||||||
|
thread_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/wait/<suuid:seed>')
|
@app.route('/wait/<suuid:seed>')
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
|
|||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||||
|
|
||||||
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
return publish_parts(text, writer='html', settings=None, settings_overrides={
|
||||||
'raw_enable': False,
|
'raw_enable': False,
|
||||||
'file_insertion_enabled': False,
|
'file_insertion_enabled': False,
|
||||||
'output_encoding': 'unicode'
|
'output_encoding': 'unicode'
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ pony>=0.7.19; python_version <= '3.12'
|
|||||||
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||||
waitress>=3.0.2
|
waitress>=3.0.2
|
||||||
Flask-Caching>=2.3.0
|
Flask-Caching>=2.3.0
|
||||||
Flask-Compress>=1.17
|
Flask-Compress>=1.17; python_version >= '3.12'
|
||||||
|
Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency
|
||||||
Flask-Limiter>=3.12
|
Flask-Limiter>=3.12
|
||||||
bokeh>=3.6.3
|
bokeh>=3.6.3
|
||||||
markupsafe>=3.0.2
|
markupsafe>=3.0.2
|
||||||
setproctitle>=1.3.5
|
setproctitle>=1.3.5
|
||||||
mistune>=3.1.3
|
mistune>=3.1.3
|
||||||
|
docutils>=0.22.2
|
||||||
|
|||||||
@@ -1,40 +1,83 @@
|
|||||||
# apworld Specification
|
# APWorld Specification
|
||||||
|
|
||||||
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
||||||
Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
These are called "APWorlds".
|
||||||
|
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
|
||||||
See [world api.md](world%20api.md) for details.
|
See [world api.md](world%20api.md) for details.
|
||||||
|
APWorlds can either be a folder, or they can be packaged as an .apworld file.
|
||||||
|
|
||||||
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
|
## .apworld File Format
|
||||||
file into the worlds folder.
|
|
||||||
|
|
||||||
**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution
|
||||||
|
by placing a `*.apworld` file into the worlds folder.
|
||||||
|
|
||||||
|
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
|
||||||
## File Format
|
|
||||||
|
|
||||||
apworld files are zip archives, all lower case, with the file ending `.apworld`.
|
|
||||||
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
|
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
|
||||||
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
|
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
|
||||||
|
|
||||||
|
**Warning:** `.apworld` files have to be all lower case,
|
||||||
|
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive.
|
Metadata about the APWorld is defined in an `archipelago.json` file.
|
||||||
The current format version has at minimum:
|
|
||||||
|
If the APWorld is a folder, the only required field is "game":
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 6,
|
"game": "Game Name"
|
||||||
"compatible_version": 5,
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
There are also the following optional fields:
|
||||||
|
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
|
||||||
|
Archipelago version respectively to filter those files from being loaded.
|
||||||
|
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
|
||||||
|
An APWorld without a world_version is always treated as older than one with a version
|
||||||
|
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
|
||||||
|
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
|
||||||
|
package managers. Should always be a list of strings.
|
||||||
|
|
||||||
|
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
|
||||||
|
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
|
||||||
|
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
|
||||||
|
["Build apworlds" launcher component](#build-apworlds-launcher-component),
|
||||||
|
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
|
||||||
|
|
||||||
|
### "Build apworlds" Launcher Component
|
||||||
|
|
||||||
|
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
|
||||||
|
and add `archipelago.json` manifest files to them.
|
||||||
|
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
|
||||||
|
The `archipelago.json` file in each .apworld will automatically include the appropriate
|
||||||
|
`version` and `compatible_version`.
|
||||||
|
|
||||||
|
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
|
||||||
|
So, a world folder with an `archipelago.json` that looks like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"game": "Game Name",
|
||||||
|
"minimum_ap_version": "0.6.4",
|
||||||
|
"world_version": "2.1.4",
|
||||||
|
"authors": ["NewSoupVi"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"minimum_ap_version": "0.6.4",
|
||||||
|
"world_version": "2.1.4",
|
||||||
|
"authors": ["NewSoupVi"],
|
||||||
|
"version": 7,
|
||||||
|
"compatible_version": 7,
|
||||||
"game": "Game Name"
|
"game": "Game Name"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build:
|
This is the recommended workflow for packaging your world to an `.apworld`.
|
||||||
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
|
|
||||||
Archipelago version respectively to filter those files from being loaded
|
|
||||||
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
|
|
||||||
An apworld without a world_version is always treated as older than one with a version
|
|
||||||
|
|
||||||
|
|
||||||
## Extra Data
|
## Extra Data
|
||||||
|
|
||||||
@@ -43,7 +86,7 @@ The zip can contain arbitrary files in addition what was specified above.
|
|||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
|
Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions`
|
||||||
|
|
||||||
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
|
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
|
||||||
`from worlds.AutoWorld import World`
|
`from worlds.AutoWorld import World`
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{a
|
|||||||
|
|
||||||
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||||
|
|
||||||
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||||
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||||
|
|||||||
11
kvui.py
11
kvui.py
@@ -34,6 +34,17 @@ from kivy.config import Config
|
|||||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||||
Config.set("kivy", "exit_on_escape", "0")
|
Config.set("kivy", "exit_on_escape", "0")
|
||||||
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
|
||||||
|
|
||||||
|
# Workaround for an issue where importing kivy.core.window before loading sounds
|
||||||
|
# will hang the whole application on Linux once the first sound is loaded.
|
||||||
|
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
|
||||||
|
# No longer necessary when we switch to kivy 3.0.0, which fixes this issue.
|
||||||
|
from kivy.core.audio import SoundLoader
|
||||||
|
for classobj in SoundLoader._classes:
|
||||||
|
# The least invasive way to force a SoundLoader class to load its audio engine seems to be calling
|
||||||
|
# .extensions(), which e.g. in audio_sdl2.pyx then calls a function called "mix_init()"
|
||||||
|
classobj.extensions()
|
||||||
|
|
||||||
from kivymd.uix.divider import MDDivider
|
from kivymd.uix.divider import MDDivider
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from kivy.core.clipboard import Clipboard
|
from kivy.core.clipboard import Clipboard
|
||||||
|
|||||||
16
ruff.toml
Normal file
16
ruff.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
line-length = 120
|
||||||
|
indent-width = 4
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = ["B", "C", "E", "F", "W", "I", "N", "Q", "UP", "RET", "RSE", "RUF", "ISC", "PLC", "PLE", "PLW", "T20", "PERF"]
|
||||||
|
ignore = [
|
||||||
|
"B011", # In AP, the use of assert False is essential because we optimise out these statements for release builds.
|
||||||
|
"C901", # Author disagrees with limiting branch complexity
|
||||||
|
"N818", # Author agrees with this rule, but Core AP violates this and changing it would be a hassle.
|
||||||
|
"PLC0415", # In AP, we consider local imports totally fine & necessary
|
||||||
|
"PLC1802", # Author agrees with this rule, but it literally changes the functionality of the code, which is unsafe.
|
||||||
|
"PLC1901", # This is just not equivalent
|
||||||
|
"PLE1141", # Gives false positives when the dict keys are tuples, but does not mention this in the suggested fix.
|
||||||
|
"UP015", # Explicit is better than implicit, so we'd prefer to keep "r" in open() calls.
|
||||||
|
]
|
||||||
12
settings.py
12
settings.py
@@ -579,6 +579,17 @@ class ServerOptions(Group):
|
|||||||
"goal" -> Client can ask for remaining items after goal completion
|
"goal" -> Client can ask for remaining items after goal completion
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
class CountdownMode(str):
|
||||||
|
"""
|
||||||
|
Countdown modes
|
||||||
|
Determines whether or not a player can initiate a countdown with !countdown
|
||||||
|
Note that /countdown is always available to the host.
|
||||||
|
|
||||||
|
"enabled" -> Client can always initiate a countdown with !countdown.
|
||||||
|
"disabled" -> Client can never initiate a countdown with !countdown.
|
||||||
|
"auto" -> !countdown will be available for any room with less than 30 slots.
|
||||||
|
"""
|
||||||
|
|
||||||
class AutoShutdown(int):
|
class AutoShutdown(int):
|
||||||
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
|
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
|
||||||
|
|
||||||
@@ -613,6 +624,7 @@ class ServerOptions(Group):
|
|||||||
release_mode: ReleaseMode = ReleaseMode("auto")
|
release_mode: ReleaseMode = ReleaseMode("auto")
|
||||||
collect_mode: CollectMode = CollectMode("auto")
|
collect_mode: CollectMode = CollectMode("auto")
|
||||||
remaining_mode: RemainingMode = RemainingMode("goal")
|
remaining_mode: RemainingMode = RemainingMode("goal")
|
||||||
|
countdown_mode: CountdownMode = CountdownMode("auto")
|
||||||
auto_shutdown: AutoShutdown = AutoShutdown(0)
|
auto_shutdown: AutoShutdown = AutoShutdown(0)
|
||||||
compatibility: Compatibility = Compatibility(2)
|
compatibility: Compatibility = Compatibility(2)
|
||||||
log_network: LogNetwork = LogNetwork(0)
|
log_network: LogNetwork = LogNetwork(0)
|
||||||
|
|||||||
14
setup.py
14
setup.py
@@ -146,7 +146,16 @@ def download_SNI() -> None:
|
|||||||
|
|
||||||
signtool: str | None = None
|
signtool: str | None = None
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
|
import socket
|
||||||
|
|
||||||
|
sign_host, sign_port = "192.168.206.4", 12345
|
||||||
|
# check if the sign_host is on a local network
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect((sign_host, sign_port))
|
||||||
|
if s.getsockname()[0].rsplit(".", 1)[0] != sign_host.rsplit(".", 1)[0]:
|
||||||
|
raise ConnectionError() # would go through default route
|
||||||
|
# configure signtool
|
||||||
|
with urllib.request.urlopen(f"http://{sign_host}:{sign_port}/connector/status") as response:
|
||||||
html = response.read()
|
html = response.read()
|
||||||
if b"status=OK\n" in html:
|
if b"status=OK\n" in html:
|
||||||
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
||||||
@@ -381,7 +390,8 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
|||||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||||
world_directory = self.libfolder / "worlds" / file_name
|
world_directory = self.libfolder / "worlds" / file_name
|
||||||
if os.path.isfile(world_directory / "archipelago.json"):
|
if os.path.isfile(world_directory / "archipelago.json"):
|
||||||
manifest = json.load(open(world_directory / "archipelago.json"))
|
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
||||||
|
manifest = json.load(manifest_file)
|
||||||
|
|
||||||
assert "game" in manifest, (
|
assert "game" in manifest, (
|
||||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
def run_locations_benchmark():
|
def run_locations_benchmark(freeze_gc: bool = True) -> None:
|
||||||
|
"""
|
||||||
|
Run a benchmark of location access rule performance against an empty_state and an all_state.
|
||||||
|
|
||||||
|
:param freeze_gc: Whether to freeze gc before benchmarking and unfreeze gc afterward. Freezing gc moves all objects
|
||||||
|
tracked by the garbage collector to a permanent generation, ignoring them in all future collections. Freezing
|
||||||
|
greatly reduces the duration of running gc.collect() within benchmarks, which otherwise often takes much longer
|
||||||
|
than running all iterations for the location rule being benchmarked.
|
||||||
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import gc
|
import gc
|
||||||
@@ -34,6 +42,8 @@ def run_locations_benchmark():
|
|||||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
||||||
|
|
||||||
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
||||||
|
if freeze_gc:
|
||||||
|
gc.freeze()
|
||||||
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
||||||
f"runs of {test_location}.access_rule({state_name})", logger) as t:
|
f"runs of {test_location}.access_rule({state_name})", logger) as t:
|
||||||
for _ in range(self.rule_iterations):
|
for _ in range(self.rule_iterations):
|
||||||
@@ -41,6 +51,8 @@ def run_locations_benchmark():
|
|||||||
# if time is taken to disentangle complex ref chains,
|
# if time is taken to disentangle complex ref chains,
|
||||||
# this time should be attributed to the rule.
|
# this time should be attributed to the rule.
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
if freeze_gc:
|
||||||
|
gc.unfreeze()
|
||||||
return t.dif
|
return t.dif
|
||||||
|
|
||||||
def main(self):
|
def main(self):
|
||||||
@@ -64,9 +76,13 @@ def run_locations_benchmark():
|
|||||||
|
|
||||||
gc.collect()
|
gc.collect()
|
||||||
for step in self.gen_steps:
|
for step in self.gen_steps:
|
||||||
|
if freeze_gc:
|
||||||
|
gc.freeze()
|
||||||
with TimeIt(f"{game} step {step}", logger):
|
with TimeIt(f"{game} step {step}", logger):
|
||||||
call_all(multiworld, step)
|
call_all(multiworld, step)
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
if freeze_gc:
|
||||||
|
gc.unfreeze()
|
||||||
|
|
||||||
locations = sorted(multiworld.get_unfilled_locations())
|
locations = sorted(multiworld.get_unfilled_locations())
|
||||||
if not locations:
|
if not locations:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from BaseClasses import PlandoOptions
|
from BaseClasses import PlandoOptions
|
||||||
from Options import ItemLinks, Choice
|
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
|
||||||
from Utils import restricted_dumps
|
from Utils import restricted_dumps
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
|
|||||||
for link in item_links.values():
|
for link in item_links.values():
|
||||||
self.assertEqual(link.value[0], item_link_group[0])
|
self.assertEqual(link.value[0], item_link_group[0])
|
||||||
|
|
||||||
def test_pickle_dumps(self):
|
def test_pickle_dumps_default(self):
|
||||||
"""Test options can be pickled into database for WebHost generation"""
|
"""Test that default option values can be pickled into database for WebHost generation"""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
if not world_type.hidden:
|
if not world_type.hidden:
|
||||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||||
@@ -81,3 +81,23 @@ class TestOptions(unittest.TestCase):
|
|||||||
restricted_dumps(option.from_any(option.default))
|
restricted_dumps(option.from_any(option.default))
|
||||||
if issubclass(option, Choice) and option.default in option.name_lookup:
|
if issubclass(option, Choice) and option.default in option.name_lookup:
|
||||||
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
||||||
|
|
||||||
|
def test_pickle_dumps_plando(self):
|
||||||
|
"""Test that plando options using containers of a custom type can be pickled"""
|
||||||
|
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
|
||||||
|
class TestPlandoConnections(PlandoConnections):
|
||||||
|
entrances = {"An Entrance"}
|
||||||
|
exits = {"An Exit"}
|
||||||
|
plando_connection_value = PlandoConnections(
|
||||||
|
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
|
||||||
|
)
|
||||||
|
|
||||||
|
plando_values = {
|
||||||
|
"PlandoConnections": plando_connection_value,
|
||||||
|
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
|
||||||
|
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
|
||||||
|
}
|
||||||
|
|
||||||
|
for option_key, value in plando_values.items():
|
||||||
|
with self.subTest(option=option_key):
|
||||||
|
restricted_dumps(value)
|
||||||
|
|||||||
102
test/general/test_world_manifest.py
Normal file
102
test/general/test_world_manifest.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Check world sources' manifest files"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
import test
|
||||||
|
from Utils import home_path, local_path
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
from ..param import classvar_matrix
|
||||||
|
|
||||||
|
|
||||||
|
test_path = Path(test.__file__).parent
|
||||||
|
worlds_paths = [
|
||||||
|
Path(local_path("worlds")),
|
||||||
|
Path(local_path("custom_worlds")),
|
||||||
|
Path(home_path("worlds")),
|
||||||
|
Path(home_path("custom_worlds")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
|
||||||
|
source_world_names = [
|
||||||
|
k
|
||||||
|
for k, v in AutoWorldRegister.world_types.items()
|
||||||
|
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_world_manifest_path(game: str) -> Path | None:
|
||||||
|
"""Get path of archipelago.json in the world's root folder from game name."""
|
||||||
|
# TODO: add a feature to AutoWorld that makes this less annoying
|
||||||
|
world_type = AutoWorldRegister.world_types[game]
|
||||||
|
world_type_path = Path(world_type.__file__)
|
||||||
|
for worlds_path in worlds_paths:
|
||||||
|
if world_type_path.is_relative_to(worlds_path):
|
||||||
|
world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0]
|
||||||
|
manifest_path = world_root / "archipelago.json"
|
||||||
|
return manifest_path if manifest_path.exists() else None
|
||||||
|
assert False, f"{world_type_path} not found in any worlds path"
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: remove the filter once manifests are mandatory.
|
||||||
|
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
|
||||||
|
class TestWorldManifest(unittest.TestCase):
|
||||||
|
game: ClassVar[str]
|
||||||
|
manifest: ClassVar[dict[str, Any]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
world_type = AutoWorldRegister.world_types[cls.game]
|
||||||
|
assert world_type.game == cls.game
|
||||||
|
manifest_path = get_source_world_manifest_path(cls.game)
|
||||||
|
assert manifest_path # make mypy happy
|
||||||
|
with manifest_path.open("r", encoding="utf-8") as f:
|
||||||
|
cls.manifest = json.load(f)
|
||||||
|
|
||||||
|
def test_game(self) -> None:
|
||||||
|
"""Test that 'game' will be correctly defined when generating APWorld manifest from source."""
|
||||||
|
self.assertIn(
|
||||||
|
"game",
|
||||||
|
self.manifest,
|
||||||
|
f"archipelago.json manifest exists for {self.game} but does not contain 'game'",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.manifest["game"],
|
||||||
|
self.game,
|
||||||
|
f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_world_version(self) -> None:
|
||||||
|
"""Test that world_version matches the requirements in apworld specification.md"""
|
||||||
|
if "world_version" in self.manifest:
|
||||||
|
world_version: str = self.manifest["world_version"]
|
||||||
|
self.assertIsInstance(
|
||||||
|
world_version,
|
||||||
|
str,
|
||||||
|
f"world_version in archipelago.json for '{self.game}' has to be string if provided.",
|
||||||
|
)
|
||||||
|
parts = world_version.split(".")
|
||||||
|
self.assertEqual(
|
||||||
|
len(parts),
|
||||||
|
3,
|
||||||
|
f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.",
|
||||||
|
)
|
||||||
|
for part in parts:
|
||||||
|
self.assertTrue(
|
||||||
|
part.isdigit(),
|
||||||
|
f"world_version in archipelago.json for '{self.game}' may only contain numbers.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_container_version(self) -> None:
|
||||||
|
self.assertNotIn(
|
||||||
|
"version",
|
||||||
|
self.manifest,
|
||||||
|
f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"compatible_version",
|
||||||
|
self.manifest,
|
||||||
|
f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.",
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
# Run with `python test/hosting` instead,
|
# Run with `python test/hosting` instead,
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -11,7 +12,7 @@ from test.hosting.client import Client
|
|||||||
from test.hosting.generate import generate_local
|
from test.hosting.generate import generate_local
|
||||||
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
|
||||||
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
|
||||||
stop_autohost, upload_multidata)
|
stop_autogen, stop_autohost, upload_multidata, generate_remote)
|
||||||
from test.hosting.world import copy as copy_world, delete as delete_world
|
from test.hosting.world import copy as copy_world, delete as delete_world
|
||||||
|
|
||||||
failure = False
|
failure = False
|
||||||
@@ -56,35 +57,62 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
warnings.simplefilter("ignore", ResourceWarning)
|
warnings.simplefilter("ignore", ResourceWarning)
|
||||||
warnings.simplefilter("ignore", UserWarning)
|
warnings.simplefilter("ignore", UserWarning)
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
|
||||||
spacer = '=' * 80
|
spacer = '=' * 80
|
||||||
|
|
||||||
with TemporaryDirectory() as tempdir:
|
with TemporaryDirectory() as tempdir:
|
||||||
|
empty_file = str(Path(tempdir) / "empty")
|
||||||
|
open(empty_file, "w").close()
|
||||||
|
sys.argv += ["--config_override", empty_file] # tests #5541
|
||||||
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
|
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
|
||||||
p1_games = []
|
p1_games: list[str] = []
|
||||||
data_paths = []
|
data_paths: list[Path | None] = []
|
||||||
rooms = []
|
rooms: list[str] = []
|
||||||
|
multidata: Path | None
|
||||||
|
|
||||||
copy_world("VVVVVV", "Temp World")
|
copy_world("VVVVVV", "Temp World")
|
||||||
try:
|
try:
|
||||||
for n, games in enumerate(multis, 1):
|
for n, games in enumerate(multis, 1):
|
||||||
print(f"Generating [{n}] {', '.join(games)}")
|
print(f"Generating [{n}] {', '.join(games)} offline")
|
||||||
multidata = generate_local(games, tempdir)
|
multidata = generate_local(games, tempdir)
|
||||||
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
||||||
p1_games.append(games[0])
|
|
||||||
data_paths.append(multidata)
|
data_paths.append(multidata)
|
||||||
|
p1_games.append(games[0])
|
||||||
finally:
|
finally:
|
||||||
delete_world("Temp World")
|
delete_world("Temp World")
|
||||||
|
|
||||||
webapp = get_app(tempdir)
|
webapp = get_app(tempdir)
|
||||||
webhost_client = webapp.test_client()
|
webhost_client = webapp.test_client()
|
||||||
|
|
||||||
for n, multidata in enumerate(data_paths, 1):
|
for n, multidata in enumerate(data_paths, 1):
|
||||||
|
assert multidata
|
||||||
seed = upload_multidata(webhost_client, multidata)
|
seed = upload_multidata(webhost_client, multidata)
|
||||||
|
print(f"Uploaded [{n}] {multidata} as {seed}\n")
|
||||||
room = create_room(webhost_client, seed)
|
room = create_room(webhost_client, seed)
|
||||||
print(f"Uploaded [{n}] {multidata} as {room}\n")
|
print(f"Started [{n}] {seed} as {room}\n")
|
||||||
|
rooms.append(room)
|
||||||
|
|
||||||
|
# Generate 1 extra game on WebHost
|
||||||
|
from WebHostLib.autolauncher import autogen
|
||||||
|
for n, games in enumerate(multis[:1], len(multis) + 1):
|
||||||
|
multis.append(games)
|
||||||
|
try:
|
||||||
|
print(f"Generating [{n}] {', '.join(games)} online")
|
||||||
|
autogen(webapp.config)
|
||||||
|
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
|
||||||
|
seed = generate_remote(webhost_client, games)
|
||||||
|
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
|
||||||
|
finally:
|
||||||
|
stop_autogen()
|
||||||
|
data_paths.append(None) # WebHost-only
|
||||||
|
room = create_room(webhost_client, seed)
|
||||||
|
print(f"Started [{n}] {seed} as {room}\n")
|
||||||
rooms.append(room)
|
rooms.append(room)
|
||||||
|
|
||||||
print("Starting autohost")
|
print("Starting autohost")
|
||||||
@@ -96,31 +124,10 @@ if __name__ == "__main__":
|
|||||||
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
|
||||||
involved_games = {"Archipelago"} | set(multi_games)
|
involved_games = {"Archipelago"} | set(multi_games)
|
||||||
for collected_items in range(3):
|
for collected_items in range(3):
|
||||||
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
|
||||||
with LocalServeGame(multidata) as host:
|
|
||||||
with Client(host.address, game, "Player1") as client:
|
|
||||||
local_data_packages = client.games_packages
|
|
||||||
local_collected_items = len(client.checked_locations)
|
|
||||||
if collected_items < 2: # Don't collect anything on the last iteration
|
|
||||||
client.collect_any()
|
|
||||||
# TODO: Ctrl+C test here as well
|
|
||||||
|
|
||||||
for game_name in sorted(involved_games):
|
|
||||||
expect_true(game_name in local_data_packages,
|
|
||||||
f"{game_name} missing from MultiServer datap ackage")
|
|
||||||
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
|
||||||
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
|
||||||
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
|
||||||
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
|
||||||
for game_name in local_data_packages:
|
|
||||||
expect_true(game_name in involved_games,
|
|
||||||
f"Received unexpected extra data package for {game_name} from MultiServer")
|
|
||||||
assert_equal(local_collected_items, collected_items,
|
|
||||||
"MultiServer did not load or save correctly")
|
|
||||||
|
|
||||||
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
|
||||||
prev_host_adr: str
|
prev_host_adr: str
|
||||||
with WebHostServeGame(webhost_client, room) as host:
|
with WebHostServeGame(webhost_client, room) as host:
|
||||||
|
sleep(.1) # wait for the server to fully start before doing anything
|
||||||
prev_host_adr = host.address
|
prev_host_adr = host.address
|
||||||
with Client(host.address, game, "Player1") as client:
|
with Client(host.address, game, "Player1") as client:
|
||||||
web_data_packages = client.games_packages
|
web_data_packages = client.games_packages
|
||||||
@@ -134,6 +141,7 @@ if __name__ == "__main__":
|
|||||||
autohost(webapp.config) # this will spin the room right up again
|
autohost(webapp.config) # this will spin the room right up again
|
||||||
sleep(1) # make log less annoying
|
sleep(1) # make log less annoying
|
||||||
# if saving failed, the next iteration will fail below
|
# if saving failed, the next iteration will fail below
|
||||||
|
sleep(2) # work around issue #5571
|
||||||
|
|
||||||
# verify server shut down
|
# verify server shut down
|
||||||
try:
|
try:
|
||||||
@@ -156,6 +164,31 @@ if __name__ == "__main__":
|
|||||||
"customserver did not load or save correctly during/after "
|
"customserver did not load or save correctly during/after "
|
||||||
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
|
||||||
|
|
||||||
|
if not multidata:
|
||||||
|
continue # games rolled on WebHost can not be tested against MultiServer
|
||||||
|
|
||||||
|
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
|
||||||
|
with LocalServeGame(multidata) as host:
|
||||||
|
with Client(host.address, game, "Player1") as client:
|
||||||
|
local_data_packages = client.games_packages
|
||||||
|
local_collected_items = len(client.checked_locations)
|
||||||
|
if collected_items < 2: # Don't collect anything on the last iteration
|
||||||
|
client.collect_any()
|
||||||
|
# TODO: Ctrl+C test here as well
|
||||||
|
|
||||||
|
for game_name in sorted(involved_games):
|
||||||
|
expect_true(game_name in local_data_packages,
|
||||||
|
f"{game_name} missing from MultiServer datapackage")
|
||||||
|
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
|
||||||
|
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||||
|
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
|
||||||
|
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
|
||||||
|
for game_name in local_data_packages:
|
||||||
|
expect_true(game_name in involved_games,
|
||||||
|
f"Received unexpected extra data package for {game_name} from MultiServer")
|
||||||
|
assert_equal(local_collected_items, collected_items,
|
||||||
|
"MultiServer did not load or save correctly")
|
||||||
|
|
||||||
# compare customserver to MultiServer
|
# compare customserver to MultiServer
|
||||||
expect_equal(local_data_packages, web_data_packages,
|
expect_equal(local_data_packages, web_data_packages,
|
||||||
"customserver datapackage differs from MultiServer")
|
"customserver datapackage differs from MultiServer")
|
||||||
@@ -176,10 +209,12 @@ if __name__ == "__main__":
|
|||||||
print(f"Restoring multidata for {room}")
|
print(f"Restoring multidata for {room}")
|
||||||
set_multidata_for_room(webhost_client, room, old_data)
|
set_multidata_for_room(webhost_client, room, old_data)
|
||||||
with WebHostServeGame(webhost_client, room) as host:
|
with WebHostServeGame(webhost_client, room) as host:
|
||||||
|
sleep(.1) # wait for the server to fully start before doing anything
|
||||||
with Client(host.address, game, "Player1") as client:
|
with Client(host.address, game, "Player1") as client:
|
||||||
assert_equal(len(client.checked_locations), 2,
|
assert_equal(len(client.checked_locations), 2,
|
||||||
"Save was destroyed during exception in customserver")
|
"Save was destroyed during exception in customserver")
|
||||||
print("Save file is not busted 🥳")
|
print("Save file is not busted 🥳")
|
||||||
|
sleep(2) # work around issue #5571
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
print("Stopping autohost")
|
print("Stopping autohost")
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, cast
|
from typing import TYPE_CHECKING, Iterable, Optional, cast
|
||||||
|
|
||||||
from WebHostLib import to_python
|
from WebHostLib import to_python
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_app",
|
"get_app",
|
||||||
|
"generate_remote",
|
||||||
"upload_multidata",
|
"upload_multidata",
|
||||||
"create_room",
|
"create_room",
|
||||||
"start_room",
|
"start_room",
|
||||||
@@ -17,6 +22,7 @@ __all__ = [
|
|||||||
"set_room_timeout",
|
"set_room_timeout",
|
||||||
"get_multidata_for_room",
|
"get_multidata_for_room",
|
||||||
"set_multidata_for_room",
|
"set_multidata_for_room",
|
||||||
|
"stop_autogen",
|
||||||
"stop_autohost",
|
"stop_autohost",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask":
|
|||||||
"TESTING": True,
|
"TESTING": True,
|
||||||
"HOST_ADDRESS": "localhost",
|
"HOST_ADDRESS": "localhost",
|
||||||
"HOSTERS": 1,
|
"HOSTERS": 1,
|
||||||
|
"GENERATORS": 1,
|
||||||
|
"JOB_THRESHOLD": 1,
|
||||||
})
|
})
|
||||||
return get_app()
|
return get_app()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
|
||||||
|
data = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
|
||||||
|
for n, game in enumerate(games, 1):
|
||||||
|
name = f"{n}.yaml"
|
||||||
|
zip_file.writestr(name, json.dumps({
|
||||||
|
"name": f"Player{n}",
|
||||||
|
"game": game,
|
||||||
|
game: {},
|
||||||
|
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
|
||||||
|
}))
|
||||||
|
data.seek(0)
|
||||||
|
response = app_client.post("/generate", content_type="multipart/form-data", data={
|
||||||
|
"file": (data, "yamls.zip"),
|
||||||
|
})
|
||||||
|
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
|
||||||
|
assert "Location" in response.headers, f"Starting gen failed: no redirect"
|
||||||
|
location = response.headers["Location"]
|
||||||
|
assert isinstance(location, str)
|
||||||
|
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
|
||||||
|
for attempt in range(10):
|
||||||
|
response = app_client.get(location)
|
||||||
|
if "Location" in response.headers:
|
||||||
|
location = response.headers["Location"]
|
||||||
|
assert isinstance(location, str)
|
||||||
|
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
|
||||||
|
return location[6:]
|
||||||
|
time.sleep(1)
|
||||||
|
raise TimeoutError("WebHost gen did not finish")
|
||||||
|
|
||||||
|
|
||||||
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
|
||||||
response = app_client.post("/uploads", data={
|
response = app_client.post("/uploads", data={
|
||||||
"file": multidata.open("rb"),
|
"file": multidata.open("rb"),
|
||||||
@@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
|
|||||||
room.seed.multidata = data
|
room.seed.multidata = data
|
||||||
|
|
||||||
|
|
||||||
def stop_autohost(graceful: bool = True) -> None:
|
def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
@@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None:
|
|||||||
|
|
||||||
stop()
|
stop()
|
||||||
proc: multiprocessing.process.BaseProcess
|
proc: multiprocessing.process.BaseProcess
|
||||||
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
|
for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()):
|
||||||
|
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
|
||||||
|
# and ungraceful may not save the game
|
||||||
|
if proc.pid == os.getpid():
|
||||||
|
continue
|
||||||
if graceful and proc.pid:
|
if graceful and proc.pid:
|
||||||
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
||||||
else:
|
else:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
try:
|
try:
|
||||||
proc.join(30)
|
try:
|
||||||
|
proc.join(30)
|
||||||
|
except TimeoutError:
|
||||||
|
raise
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
|
||||||
|
proc.join(30)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
proc.join()
|
proc.join()
|
||||||
|
|
||||||
|
def stop_autogen(graceful: bool = True) -> None:
|
||||||
|
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
|
||||||
|
_stop_webhost_mp("SpawnPoolWorker-", graceful)
|
||||||
|
|
||||||
|
def stop_autohost(graceful: bool = True) -> None:
|
||||||
|
_stop_webhost_mp("MultiHoster", graceful)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
|
|||||||
|
|
||||||
def copy(src: str, dst: str) -> None:
|
def copy(src: str, dst: str) -> None:
|
||||||
from Utils import get_file_safe_name
|
from Utils import get_file_safe_name
|
||||||
from worlds import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
|
|
||||||
assert dst not in _new_worlds, "World already created"
|
assert dst not in _new_worlds, "World already created"
|
||||||
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
||||||
|
|||||||
14
test/utils/test_daemon_thread_pool.py
Normal file
14
test/utils/test_daemon_thread_pool.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from Utils import DaemonThreadPoolExecutor
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonThreadPoolExecutorTest(unittest.TestCase):
|
||||||
|
def test_is_daemon(self) -> None:
|
||||||
|
def run() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with DaemonThreadPoolExecutor(1) as executor:
|
||||||
|
executor.submit(run)
|
||||||
|
|
||||||
|
self.assertTrue(next(iter(executor._threads)).daemon)
|
||||||
@@ -217,8 +217,6 @@ components: List[Component] = [
|
|||||||
description="Install an APWorld to play games not included with Archipelago by default."),
|
description="Install an APWorld to play games not included with Archipelago by default."),
|
||||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
|
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
|
||||||
description="Connect to a multiworld using the text client."),
|
description="Connect to a multiworld using the text client."),
|
||||||
Component('Links Awakening DX Client', 'LinksAwakeningClient',
|
|
||||||
file_identifier=SuffixIdentifier('.apladx')),
|
|
||||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||||
# Ocarina of Time
|
# Ocarina of Time
|
||||||
Component('OoT Client', 'OoTClient',
|
Component('OoT Client', 'OoTClient',
|
||||||
@@ -273,7 +271,8 @@ if not is_frozen():
|
|||||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||||
world_directory = os.path.join("worlds", file_name)
|
world_directory = os.path.join("worlds", file_name)
|
||||||
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
|
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
|
||||||
manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
|
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
||||||
|
manifest = json.load(manifest_file)
|
||||||
|
|
||||||
assert "game" in manifest, (
|
assert "game" in manifest, (
|
||||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ for world_source in world_sources:
|
|||||||
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
|
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
|
||||||
for file in filenames:
|
for file in filenames:
|
||||||
if file.endswith("archipelago.json"):
|
if file.endswith("archipelago.json"):
|
||||||
manifest = json.load(open(os.path.join(dirpath, file), "r"))
|
with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
|
||||||
|
manifest = json.load(manifest_file)
|
||||||
break
|
break
|
||||||
if manifest:
|
if manifest:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
import websockets
|
import websockets
|
||||||
import functools
|
import functools
|
||||||
@@ -208,6 +210,9 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
|
|||||||
if not ctx.is_proxy_connected():
|
if not ctx.is_proxy_connected():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
|
||||||
|
msg["data"]["time"] = time.time()
|
||||||
|
|
||||||
await ctx.send_msgs([msg])
|
await ctx.send_msgs([msg])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ guaranteed_first_acts = [
|
|||||||
"Time Rift - Mafia of Cooks",
|
"Time Rift - Mafia of Cooks",
|
||||||
"Time Rift - Dead Bird Studio",
|
"Time Rift - Dead Bird Studio",
|
||||||
"Time Rift - Sleepy Subcon",
|
"Time Rift - Sleepy Subcon",
|
||||||
"Time Rift - Alpine Skyline"
|
"Time Rift - Alpine Skyline",
|
||||||
"Time Rift - Tour",
|
"Time Rift - Tour",
|
||||||
"Time Rift - Rumbi Factory",
|
"Time Rift - Rumbi Factory",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CivVIBoostData:
|
|||||||
Prereq: List[str]
|
Prereq: List[str]
|
||||||
PrereqRequiredCount: int
|
PrereqRequiredCount: int
|
||||||
Classification: str
|
Classification: str
|
||||||
|
EraRequired: bool = False
|
||||||
|
|
||||||
|
|
||||||
class GoodyHutRewardData(TypedDict):
|
class GoodyHutRewardData(TypedDict):
|
||||||
|
|||||||
@@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
|
|||||||
location = CivVILocationData(
|
location = CivVILocationData(
|
||||||
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
|
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
|
||||||
)
|
)
|
||||||
era_locations["ERA_ANCIENT"][boost.Type] = location
|
# If EraRequired is True, place the boost in its actual era
|
||||||
|
# Otherwise, place it in ERA_ANCIENT for early access
|
||||||
|
target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT"
|
||||||
|
era_locations[target_era][boost.Type] = location
|
||||||
id_base += 1
|
id_base += 1
|
||||||
|
|
||||||
return era_locations
|
return era_locations
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ boosts: List[CivVIBoostData] = [
|
|||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_TECH_SQUARE_RIGGING",
|
"BOOST_TECH_SQUARE_RIGGING",
|
||||||
"ERA_RENAISSANCE",
|
"ERA_RENAISSANCE",
|
||||||
["TECH_GUNPOWDER"],
|
["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"],
|
||||||
1,
|
3,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
@@ -252,15 +252,15 @@ boosts: List[CivVIBoostData] = [
|
|||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_TECH_BALLISTICS",
|
"BOOST_TECH_BALLISTICS",
|
||||||
"ERA_INDUSTRIAL",
|
"ERA_INDUSTRIAL",
|
||||||
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"],
|
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"],
|
||||||
2,
|
3,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_TECH_MILITARY_SCIENCE",
|
"BOOST_TECH_MILITARY_SCIENCE",
|
||||||
"ERA_INDUSTRIAL",
|
"ERA_INDUSTRIAL",
|
||||||
["TECH_STIRRUPS"],
|
["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"],
|
||||||
1,
|
3,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
@@ -301,8 +301,8 @@ boosts: List[CivVIBoostData] = [
|
|||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_TECH_REPLACEABLE_PARTS",
|
"BOOST_TECH_REPLACEABLE_PARTS",
|
||||||
"ERA_MODERN",
|
"ERA_MODERN",
|
||||||
["TECH_MILITARY_SCIENCE"],
|
["TECH_MILITARY_SCIENCE", "TECH_MINING"],
|
||||||
1,
|
2,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
@@ -343,8 +343,8 @@ boosts: List[CivVIBoostData] = [
|
|||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_TECH_ADVANCED_FLIGHT",
|
"BOOST_TECH_ADVANCED_FLIGHT",
|
||||||
"ERA_ATOMIC",
|
"ERA_ATOMIC",
|
||||||
["TECH_FLIGHT"],
|
["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"],
|
||||||
1,
|
3,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
@@ -436,8 +436,8 @@ boosts: List[CivVIBoostData] = [
|
|||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_TECH_COMPOSITES",
|
"BOOST_TECH_COMPOSITES",
|
||||||
"ERA_INFORMATION",
|
"ERA_INFORMATION",
|
||||||
["TECH_COMBUSTION"],
|
["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"],
|
||||||
1,
|
3,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
@@ -470,7 +470,7 @@ boosts: List[CivVIBoostData] = [
|
|||||||
"TECH_ELECTRICITY",
|
"TECH_ELECTRICITY",
|
||||||
"TECH_NUCLEAR_FISSION",
|
"TECH_NUCLEAR_FISSION",
|
||||||
],
|
],
|
||||||
1,
|
4,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
@@ -651,10 +651,11 @@ boosts: List[CivVIBoostData] = [
|
|||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_CIVIC_FEUDALISM",
|
"BOOST_CIVIC_FEUDALISM",
|
||||||
"ERA_MEDIEVAL",
|
"ERA_CLASSICAL",
|
||||||
[],
|
[],
|
||||||
0,
|
0,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_CIVIC_CIVIL_SERVICE",
|
"BOOST_CIVIC_CIVIL_SERVICE",
|
||||||
@@ -662,6 +663,7 @@ boosts: List[CivVIBoostData] = [
|
|||||||
[],
|
[],
|
||||||
0,
|
0,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
|
True,
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_CIVIC_MERCENARIES",
|
"BOOST_CIVIC_MERCENARIES",
|
||||||
@@ -790,6 +792,7 @@ boosts: List[CivVIBoostData] = [
|
|||||||
[],
|
[],
|
||||||
0,
|
0,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
|
True
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_CIVIC_CONSERVATION",
|
"BOOST_CIVIC_CONSERVATION",
|
||||||
@@ -885,6 +888,7 @@ boosts: List[CivVIBoostData] = [
|
|||||||
["TECH_ROCKETRY"],
|
["TECH_ROCKETRY"],
|
||||||
1,
|
1,
|
||||||
"DEFAULT",
|
"DEFAULT",
|
||||||
|
True
|
||||||
),
|
),
|
||||||
CivVIBoostData(
|
CivVIBoostData(
|
||||||
"BOOST_CIVIC_GLOBALIZATION",
|
"BOOST_CIVIC_GLOBALIZATION",
|
||||||
|
|||||||
@@ -105,3 +105,78 @@ class TestBoostsanityExcluded(CivVITestBase):
|
|||||||
if "BOOST" in location.name:
|
if "BOOST" in location.name:
|
||||||
found_locations += 1
|
found_locations += 1
|
||||||
self.assertEqual(found_locations, 0)
|
self.assertEqual(found_locations, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoostsanityEraRequired(CivVITestBase):
|
||||||
|
options = {
|
||||||
|
"boostsanity": "true",
|
||||||
|
"progression_style": "none",
|
||||||
|
"shuffle_goody_hut_rewards": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_era_required_boosts_not_accessible_early(self) -> None:
|
||||||
|
# BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL
|
||||||
|
# It should NOT be accessible in Ancient era
|
||||||
|
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||||
|
|
||||||
|
# BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL
|
||||||
|
# It should NOT be accessible in Ancient era
|
||||||
|
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||||
|
|
||||||
|
# BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC
|
||||||
|
# It should NOT be accessible in Ancient era
|
||||||
|
self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE"))
|
||||||
|
|
||||||
|
# Regular boosts without EraRequired should be accessible
|
||||||
|
self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING"))
|
||||||
|
self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION"))
|
||||||
|
|
||||||
|
def test_era_required_boosts_accessible_in_correct_era(self) -> None:
|
||||||
|
# Collect items to reach Classical era
|
||||||
|
self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing",
|
||||||
|
"Irrigation", "Sailing", "Animal Husbandry",
|
||||||
|
"State Workforce", "Foreign Trade"])
|
||||||
|
|
||||||
|
# BOOST_CIVIC_FEUDALISM should now be accessible in Classical era
|
||||||
|
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||||
|
|
||||||
|
# BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial)
|
||||||
|
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||||
|
|
||||||
|
# Collect more items to reach Industrial era
|
||||||
|
self.collect_all_but(["TECH_ROCKETRY"])
|
||||||
|
|
||||||
|
# Now BOOST_CIVIC_URBANIZATION should be accessible
|
||||||
|
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoostsanityEraRequiredWithProgression(CivVITestBase):
|
||||||
|
options = {
|
||||||
|
"boostsanity": "true",
|
||||||
|
"progression_style": "eras_and_districts",
|
||||||
|
"shuffle_goody_hut_rewards": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_era_required_with_progressive_eras(self) -> None:
|
||||||
|
# Collect all items except Progressive Era
|
||||||
|
self.collect_all_but(["Progressive Era"])
|
||||||
|
|
||||||
|
# Even with all other items, era-required boosts should not be accessible
|
||||||
|
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||||
|
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||||
|
|
||||||
|
# Collect enough Progressive Era items to reach Classical (needs 2)
|
||||||
|
self.collect(self.get_item_by_name("Progressive Era"))
|
||||||
|
self.collect(self.get_item_by_name("Progressive Era"))
|
||||||
|
|
||||||
|
# BOOST_CIVIC_FEUDALISM should now be accessible
|
||||||
|
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||||
|
|
||||||
|
# But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total)
|
||||||
|
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||||
|
|
||||||
|
# Collect 3 more Progressive Era items to reach Industrial
|
||||||
|
self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"])
|
||||||
|
|
||||||
|
# Now BOOST_CIVIC_URBANIZATION should be accessible
|
||||||
|
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import csv
|
|||||||
import enum
|
import enum
|
||||||
import math
|
import math
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from functools import reduce
|
||||||
from random import Random
|
from random import Random
|
||||||
from typing import Dict, List, Set
|
from typing import Dict, List, Set
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ def load_item_csv():
|
|||||||
item_reader = csv.DictReader(file)
|
item_reader = csv.DictReader(file)
|
||||||
for item in item_reader:
|
for item in item_reader:
|
||||||
id = int(item["id"]) if item["id"] else None
|
id = int(item["id"]) if item["id"] else None
|
||||||
classification = ItemClassification[item["classification"]]
|
classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")})
|
||||||
groups = {Group[group] for group in item["groups"].split(",") if group}
|
groups = {Group[group] for group in item["groups"].split(",") if group}
|
||||||
items.append(ItemData(id, item["name"], classification, groups))
|
items.append(ItemData(id, item["name"], classification, groups))
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ id,name,classification,groups
|
|||||||
20,Wall Jump Pack,progression,"DLC,Freemium"
|
20,Wall Jump Pack,progression,"DLC,Freemium"
|
||||||
21,Health Bar Pack,useful,"DLC,Freemium"
|
21,Health Bar Pack,useful,"DLC,Freemium"
|
||||||
22,Parallax Pack,filler,"DLC,Freemium"
|
22,Parallax Pack,filler,"DLC,Freemium"
|
||||||
23,Harmless Plants Pack,progression,"DLC,Freemium"
|
23,Harmless Plants Pack,"progression,trap","DLC,Freemium"
|
||||||
24,Death of Comedy Pack,progression,"DLC,Freemium"
|
24,Death of Comedy Pack,progression,"DLC,Freemium"
|
||||||
25,Canadian Dialog Pack,filler,"DLC,Freemium"
|
25,Canadian Dialog Pack,filler,"DLC,Freemium"
|
||||||
26,DLC NPC Pack,progression,"DLC,Freemium"
|
26,DLC NPC Pack,progression,"DLC,Freemium"
|
||||||
|
|||||||
|
@@ -16,6 +16,7 @@ logger = logging.getLogger("Client")
|
|||||||
|
|
||||||
|
|
||||||
rom_name_location = 0x07FFE3
|
rom_name_location = 0x07FFE3
|
||||||
|
player_name_location = 0x07BCC0
|
||||||
locations_array_start = 0x200
|
locations_array_start = 0x200
|
||||||
locations_array_length = 0x100
|
locations_array_length = 0x100
|
||||||
items_obtained = 0x03
|
items_obtained = 0x03
|
||||||
@@ -111,6 +112,12 @@ class FF1Client(BizHawkClient):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
|
||||||
|
auth_raw = (await bizhawk.read(
|
||||||
|
ctx.bizhawk_ctx,
|
||||||
|
[(player_name_location, 0x40, self.rom)]))[0]
|
||||||
|
ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip()
|
||||||
|
|
||||||
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
|
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
|
||||||
if ctx.server is None:
|
if ctx.server is None:
|
||||||
return
|
return
|
||||||
@@ -204,7 +211,7 @@ class FF1Client(BizHawkClient):
|
|||||||
write_list.append((location, [0], self.sram))
|
write_list.append((location, [0], self.sram))
|
||||||
elif current_item_name in no_overworld_items:
|
elif current_item_name in no_overworld_items:
|
||||||
if current_item_name == "Sigil":
|
if current_item_name == "Sigil":
|
||||||
location = 0x28
|
location = 0x2B
|
||||||
else:
|
else:
|
||||||
location = 0x12
|
location = 0x12
|
||||||
write_list.append((location, [1], self.sram))
|
write_list.append((location, [1], self.sram))
|
||||||
|
|||||||
@@ -253,5 +253,17 @@
|
|||||||
"CubeBot": 529,
|
"CubeBot": 529,
|
||||||
"Sarda": 525,
|
"Sarda": 525,
|
||||||
"Fairy": 531,
|
"Fairy": 531,
|
||||||
"Lefein": 527
|
"Lefein": 527,
|
||||||
|
"DeepDungeon32B_Chest144": 401,
|
||||||
|
"DeepDungeon30B_Chest145": 402,
|
||||||
|
"DeepDungeon29B_Chest146": 403,
|
||||||
|
"DeepDungeon29B_Chest147": 404,
|
||||||
|
"DeepDungeon40B_Chest186": 443,
|
||||||
|
"DeepDungeon38B_Chest188": 445,
|
||||||
|
"DeepDungeon36B_Chest189": 446,
|
||||||
|
"DeepDungeon33B_Chest190": 447,
|
||||||
|
"DeepDungeon40B_Chest191": 448,
|
||||||
|
"DeepDungeon41B_Chest192": 449,
|
||||||
|
"DeepDungeon34B_Chest193": 450,
|
||||||
|
"DeepDungeon39B_Chest194": 451
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import NamedTuple, Union
|
from typing import NamedTuple, Union
|
||||||
|
from typing_extensions import deprecated
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from BaseClasses import Item, Tutorial, ItemClassification
|
from BaseClasses import Item, Tutorial, ItemClassification
|
||||||
@@ -49,7 +50,8 @@ class GenericWorld(World):
|
|||||||
return Item(name, ItemClassification.filler, -1, self.player)
|
return Item(name, ItemClassification.filler, -1, self.player)
|
||||||
raise InvalidItemError(name)
|
raise InvalidItemError(name)
|
||||||
|
|
||||||
|
@deprecated("worlds.generic.PlandoItem is deprecated and will be removed in the next version. "
|
||||||
|
"Use Options.PlandoItem(s) instead.")
|
||||||
class PlandoItem(NamedTuple):
|
class PlandoItem(NamedTuple):
|
||||||
item: str
|
item: str
|
||||||
location: str
|
location: str
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* A legal copy of Hollow Knight.
|
* A legal copy of Hollow Knight.
|
||||||
* Steam, Gog, and Xbox Game Pass versions of the game are supported.
|
* Steam, Gog, and Xbox Game Pass versions of the game are supported.
|
||||||
* Windows, Mac, and Linux (including Steam Deck) are supported.
|
* Windows, Mac, and Linux (including Steam Deck) are supported.
|
||||||
|
|
||||||
|
**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods.
|
||||||
|
|
||||||
## Installing the Archipelago Mod using Lumafly
|
## Installing the Archipelago Mod using Lumafly
|
||||||
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
|
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
* Tener una copia legal de Hollow Knight.
|
* Tener una copia legal de Hollow Knight.
|
||||||
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles
|
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles
|
||||||
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
|
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
|
||||||
|
|
||||||
|
**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods)
|
||||||
|
usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y
|
||||||
|
similares, en vez de sitios web como Nexus Mods.
|
||||||
|
|
||||||
## Instalación del mod de Archipelago con Lumafly
|
## Instalación del mod de Archipelago con Lumafly
|
||||||
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
|
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
|
||||||
@@ -61,4 +65,4 @@ de Archipelago para generar un YAML usando una interfaz gráfica.
|
|||||||
## Consejos y otros comandos
|
## Consejos y otros comandos
|
||||||
Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la
|
Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la
|
||||||
[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto,
|
[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto,
|
||||||
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
* Uma cópia legal de Hollow Knight.
|
* Uma cópia legal de Hollow Knight.
|
||||||
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
|
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
|
||||||
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
|
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
|
||||||
|
|
||||||
|
**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods)
|
||||||
|
usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes,
|
||||||
|
ao invés de sites como o Nexus Mods.
|
||||||
|
|
||||||
## Instalando o mod Archipelago Mod usando Lumafly
|
## Instalando o mod Archipelago Mod usando Lumafly
|
||||||
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
|
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
|
||||||
|
|||||||
@@ -134,13 +134,13 @@ class KH1Context(CommonContext):
|
|||||||
os.makedirs(self.game_communication_path)
|
os.makedirs(self.game_communication_path)
|
||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
# Handle Slot Data
|
# Handle Slot Data
|
||||||
self.slot_data = args['slot_data']
|
self.slot_data = args['slot_data']
|
||||||
for key in list(args['slot_data'].keys()):
|
for key in list(args['slot_data'].keys()):
|
||||||
with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w') as f:
|
with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w', encoding='utf-8') as f:
|
||||||
f.write(str(args['slot_data'][key]))
|
f.write(str(args['slot_data'][key]))
|
||||||
f.close()
|
f.close()
|
||||||
if key == "remote_location_ids":
|
if key == "remote_location_ids":
|
||||||
@@ -161,7 +161,7 @@ class KH1Context(CommonContext):
|
|||||||
found = True
|
found = True
|
||||||
if not found:
|
if not found:
|
||||||
if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot:
|
if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot:
|
||||||
with open(os.path.join(self.game_communication_path, item_filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, item_filename), 'w', encoding='utf-8') as f:
|
||||||
f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player))
|
f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player))
|
||||||
f.close()
|
f.close()
|
||||||
self.item_num += 1
|
self.item_num += 1
|
||||||
@@ -170,7 +170,7 @@ class KH1Context(CommonContext):
|
|||||||
if "checked_locations" in args:
|
if "checked_locations" in args:
|
||||||
for ss in self.checked_locations:
|
for ss in self.checked_locations:
|
||||||
filename = f"send{ss}"
|
filename = f"send{ss}"
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
if cmd in {"PrintJSON"} and "type" in args:
|
if cmd in {"PrintJSON"} and "type" in args:
|
||||||
@@ -195,7 +195,7 @@ class KH1Context(CommonContext):
|
|||||||
filename = "msg"
|
filename = "msg"
|
||||||
if message != "":
|
if message != "":
|
||||||
if not os.path.exists(self.game_communication_path + "/" + filename):
|
if not os.path.exists(self.game_communication_path + "/" + filename):
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||||
f.write(message)
|
f.write(message)
|
||||||
f.close()
|
f.close()
|
||||||
if args["type"] == "ItemCheat":
|
if args["type"] == "ItemCheat":
|
||||||
@@ -207,7 +207,7 @@ class KH1Context(CommonContext):
|
|||||||
filename = "msg"
|
filename = "msg"
|
||||||
message = "Received " + itemName + "\nfrom server"
|
message = "Received " + itemName + "\nfrom server"
|
||||||
if not os.path.exists(self.game_communication_path + "/" + filename):
|
if not os.path.exists(self.game_communication_path + "/" + filename):
|
||||||
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
|
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
|
||||||
f.write(message)
|
f.write(message)
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ class KH1Context(CommonContext):
|
|||||||
logger.info(f"DeathLink: {text}")
|
logger.info(f"DeathLink: {text}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"DeathLink: Received from {data['source']}")
|
logger.info(f"DeathLink: Received from {data['source']}")
|
||||||
with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w') as f:
|
with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w', encoding='utf-8') as f:
|
||||||
f.write(str(int(data["time"])))
|
f.write(str(int(data["time"])))
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
|
|||||||
6
worlds/kh2/archipelago.json
Normal file
6
worlds/kh2/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"game": "Kingdom Hearts 2",
|
||||||
|
"authors": [ "JaredWeakStrike" ],
|
||||||
|
"minimum_ap_version": "0.6.3",
|
||||||
|
"world_version": "2.0.0"
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ from .patches import bingo as _
|
|||||||
from .patches import multiworld as _
|
from .patches import multiworld as _
|
||||||
from .patches import tradeSequence as _
|
from .patches import tradeSequence as _
|
||||||
from . import hints
|
from . import hints
|
||||||
|
from . import utils
|
||||||
|
|
||||||
from .patches import bank34
|
from .patches import bank34
|
||||||
from .roomEditor import RoomEditor, Object
|
from .roomEditor import RoomEditor, Object
|
||||||
@@ -231,10 +232,10 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
|
|||||||
rom.patch(0, 0x0003, "00", "01")
|
rom.patch(0, 0x0003, "00", "01")
|
||||||
|
|
||||||
# Patch the sword check on the shopkeeper turning around.
|
# Patch the sword check on the shopkeeper turning around.
|
||||||
#if ladxr_settings["steal"] == 'never':
|
if options["stealing"] == Options.Stealing.option_disabled:
|
||||||
# rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
|
rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
|
||||||
#elif ladxr_settings["steal"] == 'always':
|
rom.texts[0x2E] = utils.formatText("Hey! Welcome! Did you know that I have eyes on the back of my head?")
|
||||||
# rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
|
rom.texts[0x2F] = utils.formatText("Nothing escapes my gaze! Your thieving ways shall never prosper!")
|
||||||
|
|
||||||
#if ladxr_settings["hpmode"] == 'inverted':
|
#if ladxr_settings["hpmode"] == 'inverted':
|
||||||
# patches.health.setStartHealth(rom, 9)
|
# patches.health.setStartHealth(rom, 9)
|
||||||
@@ -325,7 +326,7 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
|
|||||||
0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD,
|
0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD,
|
||||||
|
|
||||||
# Prices
|
# Prices
|
||||||
0x02C, 0x02D, 0x030, 0x031, 0x032, 0x033, # Shop items
|
0x02C, 0x02D, 0x02E, 0x02F, 0x030, 0x031, 0x032, 0x033, # Shop items
|
||||||
0x03B, # Trendy Game
|
0x03B, # Trendy Game
|
||||||
0x045, # Fisherman
|
0x045, # Fisherman
|
||||||
0x018, 0x019, # Crazy Tracy
|
0x018, 0x019, # Crazy Tracy
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ class World:
|
|||||||
self._addEntrance("start_house", mabe_village, start_house, None)
|
self._addEntrance("start_house", mabe_village, start_house, None)
|
||||||
|
|
||||||
shop = Location("Shop")
|
shop = Location("Shop")
|
||||||
Location().add(ShopItem(0)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 500)), SWORD))
|
if options.steal == "inlogic":
|
||||||
Location().add(ShopItem(1)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 1480)), SWORD))
|
Location().add(ShopItem(0)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 500))))
|
||||||
|
Location().add(ShopItem(1)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 1480))))
|
||||||
|
else:
|
||||||
|
Location().add(ShopItem(0)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 500)))
|
||||||
|
Location().add(ShopItem(1)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 1480)))
|
||||||
self._addEntrance("shop", mabe_village, shop, None)
|
self._addEntrance("shop", mabe_village, shop, None)
|
||||||
|
|
||||||
dream_hut = Location("Dream Hut")
|
dream_hut = Location("Dream Hut")
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
|
|||||||
[Hero] Switch version hero mode, double damage, no heart/fairy drops.
|
[Hero] Switch version hero mode, double damage, no heart/fairy drops.
|
||||||
[One hit KO] You die on a single hit, always."""),
|
[One hit KO] You die on a single hit, always."""),
|
||||||
Setting('steal', 'Gameplay', 't', 'Stealing from the shop',
|
Setting('steal', 'Gameplay', 't', 'Stealing from the shop',
|
||||||
options=[('always', 'a', 'Always'), ('never', 'n', 'Never'), ('default', '', 'Normal')], default='default',
|
options=[('inlogic', 'a', 'In logic'), ('disabled', 'n', 'Disabled'), ('outoflogic', '', 'Out of logic')], default='outoflogic',
|
||||||
description="""Effects when you can steal from the shop. Stealing is bad and never in logic.
|
description="""Effects when you can steal from the shop and if it is in logic.
|
||||||
[Normal] requires the sword before you can steal.
|
[Normal] requires the sword before you can steal.
|
||||||
[Always] you can always steal from the shop
|
[Always] you can always steal from the shop
|
||||||
[Never] you can never steal from the shop."""),
|
[Never] you can never steal from the shop."""),
|
||||||
@@ -286,7 +286,7 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
|
|||||||
if self.goal in ("bingo", "bingo-full"):
|
if self.goal in ("bingo", "bingo-full"):
|
||||||
req("overworld", "normal", "Bingo goal does not work with dungeondive")
|
req("overworld", "normal", "Bingo goal does not work with dungeondive")
|
||||||
req("accessibility", "all", "Bingo goal needs 'all' accessibility")
|
req("accessibility", "all", "Bingo goal needs 'all' accessibility")
|
||||||
dis("steal", "never", "default", "With bingo goal, stealing should be allowed")
|
dis("steal", "disabled", "default", "With bingo goal, stealing should be allowed")
|
||||||
dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle")
|
dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle")
|
||||||
dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle")
|
dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle")
|
||||||
if self.overworld == "dungeondive":
|
if self.overworld == "dungeondive":
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
@@ -26,16 +23,14 @@ import typing
|
|||||||
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||||
server_loop)
|
server_loop)
|
||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
from worlds.ladx import LinksAwakeningWorld
|
from . import LinksAwakeningWorld
|
||||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
from .Common import BASE_ID as LABaseID
|
||||||
from worlds.ladx.GpsTracker import GpsTracker
|
from .GpsTracker import GpsTracker
|
||||||
from worlds.ladx.TrackerConsts import storage_key
|
from .TrackerConsts import storage_key
|
||||||
from worlds.ladx.ItemTracker import ItemTracker
|
from .ItemTracker import ItemTracker
|
||||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
from .LADXR.checkMetadata import checkMetadataTable
|
||||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
from .Locations import get_locations_to_id, meta_to_name
|
||||||
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
|
from .Tracker import LocationTracker, MagpieBridge, Check
|
||||||
|
|
||||||
|
|
||||||
class GameboyException(Exception):
|
class GameboyException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -760,42 +755,44 @@ def run_game(romfile: str) -> None:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
|
||||||
|
|
||||||
async def main():
|
def launch(*launch_args):
|
||||||
parser = get_base_parser(description="Link's Awakening Client.")
|
async def main():
|
||||||
parser.add_argument("--url", help="Archipelago connection url")
|
parser = get_base_parser(description="Link's Awakening Client.")
|
||||||
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
parser.add_argument("--url", help="Archipelago connection url")
|
||||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
|
||||||
help='Path to a .apladx Archipelago Binary Patch file')
|
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||||
|
help='Path to a .apladx Archipelago Binary Patch file')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(launch_args)
|
||||||
|
|
||||||
if args.diff_file:
|
if args.diff_file:
|
||||||
import Patch
|
import Patch
|
||||||
logger.info("patch file was supplied - creating rom...")
|
logger.info("patch file was supplied - creating rom...")
|
||||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||||
if "server" in meta and not args.connect:
|
if "server" in meta and not args.connect:
|
||||||
args.connect = meta["server"]
|
args.connect = meta["server"]
|
||||||
logger.info(f"wrote rom file to {rom_file}")
|
logger.info(f"wrote rom file to {rom_file}")
|
||||||
|
|
||||||
|
|
||||||
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
|
||||||
|
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||||
|
|
||||||
# TODO: nothing about the lambda about has to be in a lambda
|
# TODO: nothing about the lambda about has to be in a lambda
|
||||||
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
|
||||||
if gui_enabled:
|
if gui_enabled:
|
||||||
ctx.run_gui()
|
ctx.run_gui()
|
||||||
ctx.run_cli()
|
ctx.run_cli()
|
||||||
|
|
||||||
# Down below run_gui so that we get errors out of the process
|
# Down below run_gui so that we get errors out of the process
|
||||||
if args.diff_file:
|
if args.diff_file:
|
||||||
run_game(rom_file)
|
run_game(rom_file)
|
||||||
|
|
||||||
await ctx.exit_event.wait()
|
await ctx.exit_event.wait()
|
||||||
await ctx.shutdown()
|
await ctx.shutdown()
|
||||||
|
|
||||||
|
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
colorama.just_fix_windows_console()
|
colorama.just_fix_windows_console()
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
colorama.deinit()
|
colorama.deinit()
|
||||||
@@ -3,7 +3,7 @@ from dataclasses import dataclass
|
|||||||
import os.path
|
import os.path
|
||||||
import typing
|
import typing
|
||||||
import logging
|
import logging
|
||||||
from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed
|
from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed, StartInventoryPool
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
@@ -325,6 +325,18 @@ class HardMode(Choice, LADXROption):
|
|||||||
default = option_none
|
default = option_none
|
||||||
|
|
||||||
|
|
||||||
|
class Stealing(Choice, LADXROption):
|
||||||
|
"""
|
||||||
|
Puts stealing from the shop in logic if the player has a sword.
|
||||||
|
"""
|
||||||
|
display_name = "Stealing"
|
||||||
|
ladxr_name = "steal"
|
||||||
|
option_in_logic = 1
|
||||||
|
option_out_of_logic = 2
|
||||||
|
option_disabled = 3
|
||||||
|
default = option_out_of_logic
|
||||||
|
|
||||||
|
|
||||||
class Overworld(Choice, LADXROption):
|
class Overworld(Choice, LADXROption):
|
||||||
"""
|
"""
|
||||||
**Open Mabe:** Replaces rock on the east side of Mabe Village with bushes,
|
**Open Mabe:** Replaces rock on the east side of Mabe Village with bushes,
|
||||||
@@ -656,6 +668,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
|
|||||||
nag_messages: NagMessages
|
nag_messages: NagMessages
|
||||||
ap_title_screen: APTitleScreen
|
ap_title_screen: APTitleScreen
|
||||||
boots_controls: BootsControls
|
boots_controls: BootsControls
|
||||||
|
stealing: Stealing
|
||||||
quickswap: Quickswap
|
quickswap: Quickswap
|
||||||
hard_mode: HardMode
|
hard_mode: HardMode
|
||||||
low_hp_beep: LowHpBeep
|
low_hp_beep: LowHpBeep
|
||||||
@@ -665,6 +678,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
|
|||||||
tarins_gift: TarinsGift
|
tarins_gift: TarinsGift
|
||||||
overworld: Overworld
|
overworld: Overworld
|
||||||
stabilize_item_pool: StabilizeItemPool
|
stabilize_item_pool: StabilizeItemPool
|
||||||
|
start_inventory_from_pool: StartInventoryPool
|
||||||
|
|
||||||
warp_improvements: Removed
|
warp_improvements: Removed
|
||||||
additional_warp_points: Removed
|
additional_warp_points: Removed
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch):
|
|||||||
"nag_messages",
|
"nag_messages",
|
||||||
"ap_title_screen",
|
"ap_title_screen",
|
||||||
"boots_controls",
|
"boots_controls",
|
||||||
# "stealing",
|
"stealing",
|
||||||
"quickswap",
|
"quickswap",
|
||||||
"hard_mode",
|
"hard_mode",
|
||||||
"low_hp_beep",
|
"low_hp_beep",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import settings
|
|||||||
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial
|
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial
|
||||||
from Fill import fill_restrictive
|
from Fill import fill_restrictive
|
||||||
from worlds.AutoWorld import WebWorld, World
|
from worlds.AutoWorld import WebWorld, World
|
||||||
|
from worlds.LauncherComponents import Component, components, SuffixIdentifier, Type, launch, icon_paths
|
||||||
from .Common import *
|
from .Common import *
|
||||||
from . import ItemIconGuessing
|
from . import ItemIconGuessing
|
||||||
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
|
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
|
||||||
@@ -29,6 +30,19 @@ from .Rom import LADXProcedurePatch, write_patch_data
|
|||||||
DEVELOPER_MODE = False
|
DEVELOPER_MODE = False
|
||||||
|
|
||||||
|
|
||||||
|
def launch_client(*args):
|
||||||
|
from .LinksAwakeningClient import launch as ladx_launch
|
||||||
|
launch(ladx_launch, name=f"{LINKS_AWAKENING} Client", args=args)
|
||||||
|
|
||||||
|
components.append(Component(f"{LINKS_AWAKENING} Client",
|
||||||
|
func=launch_client,
|
||||||
|
component_type=Type.CLIENT,
|
||||||
|
icon=LINKS_AWAKENING,
|
||||||
|
file_identifier=SuffixIdentifier('.apladx')))
|
||||||
|
|
||||||
|
icon_paths[LINKS_AWAKENING] = "ap:worlds.ladx/assets/MarinV-3_small.png"
|
||||||
|
|
||||||
|
|
||||||
class LinksAwakeningSettings(settings.Group):
|
class LinksAwakeningSettings(settings.Group):
|
||||||
class RomFile(settings.UserFilePath):
|
class RomFile(settings.UserFilePath):
|
||||||
"""File name of the Link's Awakening DX rom"""
|
"""File name of the Link's Awakening DX rom"""
|
||||||
@@ -211,8 +225,6 @@ class LinksAwakeningWorld(World):
|
|||||||
def create_items(self) -> None:
|
def create_items(self) -> None:
|
||||||
itempool = []
|
itempool = []
|
||||||
|
|
||||||
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
|
|
||||||
|
|
||||||
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
|
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
|
||||||
self.prefill_own_dungeons = []
|
self.prefill_own_dungeons = []
|
||||||
self.pre_fill_items = []
|
self.pre_fill_items = []
|
||||||
@@ -229,50 +241,46 @@ class LinksAwakeningWorld(World):
|
|||||||
continue
|
continue
|
||||||
item_name = ladxr_item_to_la_item_name[ladx_item_name]
|
item_name = ladxr_item_to_la_item_name[ladx_item_name]
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
if item_name in exclude:
|
item = self.create_item(item_name)
|
||||||
exclude.remove(item_name) # this is destructive. create unique list above
|
|
||||||
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
|
|
||||||
else:
|
|
||||||
item = self.create_item(item_name)
|
|
||||||
|
|
||||||
if not self.options.tradequest and isinstance(item.item_data, TradeItemData):
|
if not self.options.tradequest and isinstance(item.item_data, TradeItemData):
|
||||||
location = self.multiworld.get_location(item.item_data.vanilla_location, self.player)
|
location = self.multiworld.get_location(item.item_data.vanilla_location, self.player)
|
||||||
location.place_locked_item(item)
|
location.place_locked_item(item)
|
||||||
location.show_in_spoiler = False
|
location.show_in_spoiler = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(item.item_data, DungeonItemData):
|
if isinstance(item.item_data, DungeonItemData):
|
||||||
item_type = item.item_data.ladxr_id[:-1]
|
item_type = item.item_data.ladxr_id[:-1]
|
||||||
shuffle_type = self.dungeon_item_types[item_type]
|
shuffle_type = self.dungeon_item_types[item_type]
|
||||||
|
|
||||||
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
|
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
|
||||||
# Find instrument, lock
|
# Find instrument, lock
|
||||||
# TODO: we should be able to pinpoint the region we want, save a lookup table please
|
# TODO: we should be able to pinpoint the region we want, save a lookup table please
|
||||||
found = False
|
found = False
|
||||||
for r in self.multiworld.get_regions(self.player):
|
for r in self.multiworld.get_regions(self.player):
|
||||||
if r.dungeon_index != item.item_data.dungeon_index:
|
if r.dungeon_index != item.item_data.dungeon_index:
|
||||||
|
continue
|
||||||
|
for loc in r.locations:
|
||||||
|
if not isinstance(loc, LinksAwakeningLocation):
|
||||||
continue
|
continue
|
||||||
for loc in r.locations:
|
if not isinstance(loc.ladxr_item, Instrument):
|
||||||
if not isinstance(loc, LinksAwakeningLocation):
|
continue
|
||||||
continue
|
loc.place_locked_item(item)
|
||||||
if not isinstance(loc.ladxr_item, Instrument):
|
found = True
|
||||||
continue
|
break
|
||||||
loc.place_locked_item(item)
|
if found:
|
||||||
found = True
|
break
|
||||||
break
|
|
||||||
if found:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
|
|
||||||
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
|
|
||||||
self.pre_fill_items.append(item)
|
|
||||||
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
|
|
||||||
self.prefill_own_dungeons.append(item)
|
|
||||||
self.pre_fill_items.append(item)
|
|
||||||
else:
|
|
||||||
itempool.append(item)
|
|
||||||
else:
|
else:
|
||||||
itempool.append(item)
|
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
|
||||||
|
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
|
||||||
|
self.pre_fill_items.append(item)
|
||||||
|
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
|
||||||
|
self.prefill_own_dungeons.append(item)
|
||||||
|
self.pre_fill_items.append(item)
|
||||||
|
else:
|
||||||
|
itempool.append(item)
|
||||||
|
else:
|
||||||
|
itempool.append(item)
|
||||||
|
|
||||||
self.multi_key = self.generate_multi_key()
|
self.multi_key = self.generate_multi_key()
|
||||||
|
|
||||||
|
|||||||
BIN
worlds/ladx/assets/MarinV-3.png
Normal file
BIN
worlds/ladx/assets/MarinV-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
worlds/ladx/assets/MarinV-3_small.png
Normal file
BIN
worlds/ladx/assets/MarinV-3_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -106,26 +106,38 @@ def tree_zone_4_midway_bell(state, player):
|
|||||||
|
|
||||||
def tree_zone_4_coins(state, player, coins):
|
def tree_zone_4_coins(state, player, coins):
|
||||||
auto_scroll = is_auto_scroll(state, player, "Tree Zone 4")
|
auto_scroll = is_auto_scroll(state, player, "Tree Zone 4")
|
||||||
reachable_coins = 0
|
entryway = 14
|
||||||
|
hall = 4
|
||||||
|
first_trip_downstairs = 31
|
||||||
|
second_trip_downstairs = 15
|
||||||
|
downstairs_with_auto_scroll = 12
|
||||||
|
final_room = 10
|
||||||
|
|
||||||
|
reachable_coins_from_start = 0
|
||||||
|
reachable_coins_from_bell = 0
|
||||||
|
|
||||||
if has_pipe_up(state, player):
|
if has_pipe_up(state, player):
|
||||||
reachable_coins += 14
|
reachable_coins_from_start += entryway
|
||||||
if has_pipe_right(state, player):
|
if has_pipe_right(state, player):
|
||||||
reachable_coins += 4
|
reachable_coins_from_start += hall
|
||||||
if has_pipe_down(state, player):
|
if has_pipe_down(state, player):
|
||||||
reachable_coins += 10
|
if auto_scroll:
|
||||||
if not auto_scroll:
|
reachable_coins_from_start += downstairs_with_auto_scroll
|
||||||
reachable_coins += 46
|
else:
|
||||||
elif state.has("Tree Zone 4 Midway Bell", player):
|
reachable_coins_from_start += final_room + first_trip_downstairs + second_trip_downstairs
|
||||||
if not auto_scroll:
|
if state.has("Tree Zone 4 Midway Bell", player):
|
||||||
if has_pipe_left(state, player):
|
if has_pipe_down(state, player) and (auto_scroll or not has_pipe_left(state, player)):
|
||||||
reachable_coins += 18
|
reachable_coins_from_bell += final_room
|
||||||
if has_pipe_down(state, player):
|
elif has_pipe_left(state, player) and not auto_scroll:
|
||||||
reachable_coins += 10
|
if has_pipe_down(state, player):
|
||||||
|
reachable_coins_from_bell += first_trip_downstairs
|
||||||
|
if has_pipe_right(state, player):
|
||||||
|
reachable_coins_from_bell += entryway + hall
|
||||||
if has_pipe_up(state, player):
|
if has_pipe_up(state, player):
|
||||||
reachable_coins += 46
|
reachable_coins_from_bell += second_trip_downstairs + final_room
|
||||||
elif has_pipe_down(state, player):
|
else:
|
||||||
reachable_coins += 10
|
reachable_coins_from_bell += entryway + hall
|
||||||
return coins <= reachable_coins
|
return coins <= max(reachable_coins_from_start, reachable_coins_from_bell)
|
||||||
|
|
||||||
|
|
||||||
def tree_zone_5_boss(state, player):
|
def tree_zone_5_boss(state, player):
|
||||||
@@ -239,12 +251,9 @@ def pumpkin_zone_4_coins(state, player, coins):
|
|||||||
|
|
||||||
|
|
||||||
def mario_zone_1_normal_exit(state, player):
|
def mario_zone_1_normal_exit(state, player):
|
||||||
if has_pipe_right(state, player):
|
return has_pipe_right(state, player) and (not is_auto_scroll(state, player, "Mario Zone 1")
|
||||||
if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Mario Zone 1 Midway Bell"], player):
|
or state.has_any(["Mushroom", "Fire Flower", "Carrot",
|
||||||
return True
|
"Mario Zone 1 Midway Bell"], player))
|
||||||
if is_auto_scroll(state, player, "Mario Zone 1"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def mario_zone_1_midway_bell(state, player):
|
def mario_zone_1_midway_bell(state, player):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"game": "Mega Man 2",
|
"game": "Mega Man 2",
|
||||||
"world_version": "0.3.2",
|
"world_version": "0.3.3",
|
||||||
"minimum_ap_version": "0.6.4"
|
"minimum_ap_version": "0.6.4"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|||||||
from . import MM2World
|
from . import MM2World
|
||||||
|
|
||||||
MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497"
|
MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497"
|
||||||
PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4"
|
PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61"
|
||||||
MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632"
|
MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632"
|
||||||
MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3"
|
MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3"
|
||||||
|
|
||||||
@@ -327,8 +327,6 @@ def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None:
|
|||||||
patch.write_byte(0x36089, pool[18]) # Intro
|
patch.write_byte(0x36089, pool[18]) # Intro
|
||||||
patch.write_byte(0x361F1, pool[19]) # Title
|
patch.write_byte(0x361F1, pool[19]) # Title
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from Utils import __version__
|
from Utils import __version__
|
||||||
patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0',
|
patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0',
|
||||||
'utf8')[:21]
|
'utf8')[:21]
|
||||||
@@ -406,7 +404,7 @@ def get_base_rom_path(file_name: str = "") -> str:
|
|||||||
return file_name
|
return file_name
|
||||||
|
|
||||||
|
|
||||||
PRG_OFFSET = 0x8ED70
|
PRG_OFFSET = 0x8F170
|
||||||
PRG_SIZE = 0x40000
|
PRG_SIZE = 0x40000
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ FlashFixTarget1:
|
|||||||
%org($808D, $0B)
|
%org($808D, $0B)
|
||||||
FlashFixTarget2:
|
FlashFixTarget2:
|
||||||
|
|
||||||
|
%org($A65C, $0B)
|
||||||
|
HeatFix:
|
||||||
|
CMP #$FF
|
||||||
|
|
||||||
%org($8015, $0D)
|
%org($8015, $0D)
|
||||||
ClearRefreshHook:
|
ClearRefreshHook:
|
||||||
; if we're already doing a fresh load of the stage select
|
; if we're already doing a fresh load of the stage select
|
||||||
|
|||||||
6
worlds/oot/archipelago.json
Normal file
6
worlds/oot/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"game": "Ocarina of Time",
|
||||||
|
"authors": ["espeon65536"],
|
||||||
|
"world_version": "7.0.0",
|
||||||
|
"minimum_ap_version": "0.6.4"
|
||||||
|
}
|
||||||
@@ -1237,7 +1237,7 @@ saffron_gym_warps = [
|
|||||||
|
|
||||||
entrance_only = [
|
entrance_only = [
|
||||||
"Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F",
|
"Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F",
|
||||||
"Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F"
|
"Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F",
|
||||||
"Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W",
|
"Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W",
|
||||||
"Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House",
|
"Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House",
|
||||||
"Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E",
|
"Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E",
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ bread_and_butter_settings = {
|
|||||||
OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default,
|
OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default,
|
||||||
OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal,
|
OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal,
|
||||||
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
|
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
|
||||||
OPTION_NAME[MissionOrder]: MissionOrder.option_blitz,
|
OPTION_NAME[MissionOrder]: MissionOrder.option_golden_path,
|
||||||
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
|
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
|
||||||
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
|
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
|
||||||
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
||||||
@@ -331,12 +331,13 @@ evil_logic_settings = {
|
|||||||
OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal,
|
OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal,
|
||||||
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
|
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
|
||||||
OPTION_NAME[MissionOrder]: MissionOrder.option_grid,
|
OPTION_NAME[MissionOrder]: MissionOrder.option_grid,
|
||||||
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
|
OPTION_NAME[RequiredTactics]: RequiredTactics.option_any_units,
|
||||||
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
|
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
|
||||||
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
||||||
OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced,
|
OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced,
|
||||||
OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines,
|
OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines,
|
||||||
OPTION_NAME[MaximumCampaignSize]: 35,
|
OPTION_NAME[MaximumCampaignSize]: 35,
|
||||||
|
OPTION_NAME[TwoStartPositions]: TwoStartPositions.option_true,
|
||||||
OPTION_NAME[StarterUnit]: StarterUnit.option_off,
|
OPTION_NAME[StarterUnit]: StarterUnit.option_off,
|
||||||
OPTION_NAME[EnableMorphling]: EnableMorphling.option_true,
|
OPTION_NAME[EnableMorphling]: EnableMorphling.option_true,
|
||||||
OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false,
|
OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) ->
|
|||||||
|
|
||||||
|
|
||||||
def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int,
|
def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int,
|
||||||
includeuseful: bool) -> bool:
|
includeuseful: bool, floating: bool) -> bool:
|
||||||
|
|
||||||
# Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres
|
# Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres
|
||||||
if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and
|
if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and
|
||||||
@@ -109,7 +109,7 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: list
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if buildings[index] == ITEMS.cutter:
|
if buildings[index] == ITEMS.cutter:
|
||||||
if buildings.index(ITEMS.stacker) < index:
|
if buildings.index(ITEMS.stacker) < index and not floating:
|
||||||
return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player)
|
return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player)
|
||||||
else:
|
else:
|
||||||
return can_cut_half(state, player)
|
return can_cut_half(state, player)
|
||||||
@@ -195,38 +195,38 @@ def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool,
|
|||||||
# Progressively connect level and upgrade regions
|
# Progressively connect level and upgrade regions
|
||||||
regions[REGIONS.main].connect(
|
regions[REGIONS.main].connect(
|
||||||
regions[REGIONS.levels_1], "Using first level building",
|
regions[REGIONS.levels_1], "Using first level building",
|
||||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False))
|
lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False, floating))
|
||||||
regions[REGIONS.levels_1].connect(
|
regions[REGIONS.levels_1].connect(
|
||||||
regions[REGIONS.levels_2], "Using second level building",
|
regions[REGIONS.levels_2], "Using second level building",
|
||||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False))
|
lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False, floating))
|
||||||
regions[REGIONS.levels_2].connect(
|
regions[REGIONS.levels_2].connect(
|
||||||
regions[REGIONS.levels_3], "Using third level building",
|
regions[REGIONS.levels_3], "Using third level building",
|
||||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 2,
|
lambda state: has_logic_list_building(state, player, level_logic_buildings, 2,
|
||||||
early_useful == OPTIONS.buildings_3))
|
early_useful == OPTIONS.buildings_3, floating))
|
||||||
regions[REGIONS.levels_3].connect(
|
regions[REGIONS.levels_3].connect(
|
||||||
regions[REGIONS.levels_4], "Using fourth level building",
|
regions[REGIONS.levels_4], "Using fourth level building",
|
||||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False))
|
lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False, floating))
|
||||||
regions[REGIONS.levels_4].connect(
|
regions[REGIONS.levels_4].connect(
|
||||||
regions[REGIONS.levels_5], "Using fifth level building",
|
regions[REGIONS.levels_5], "Using fifth level building",
|
||||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 4,
|
lambda state: has_logic_list_building(state, player, level_logic_buildings, 4,
|
||||||
early_useful == OPTIONS.buildings_5))
|
early_useful == OPTIONS.buildings_5, floating))
|
||||||
regions[REGIONS.main].connect(
|
regions[REGIONS.main].connect(
|
||||||
regions[REGIONS.upgrades_1], "Using first upgrade building",
|
regions[REGIONS.upgrades_1], "Using first upgrade building",
|
||||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False))
|
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False, floating))
|
||||||
regions[REGIONS.upgrades_1].connect(
|
regions[REGIONS.upgrades_1].connect(
|
||||||
regions[REGIONS.upgrades_2], "Using second upgrade building",
|
regions[REGIONS.upgrades_2], "Using second upgrade building",
|
||||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False))
|
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False, floating))
|
||||||
regions[REGIONS.upgrades_2].connect(
|
regions[REGIONS.upgrades_2].connect(
|
||||||
regions[REGIONS.upgrades_3], "Using third upgrade building",
|
regions[REGIONS.upgrades_3], "Using third upgrade building",
|
||||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2,
|
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2,
|
||||||
early_useful == OPTIONS.buildings_3))
|
early_useful == OPTIONS.buildings_3, floating))
|
||||||
regions[REGIONS.upgrades_3].connect(
|
regions[REGIONS.upgrades_3].connect(
|
||||||
regions[REGIONS.upgrades_4], "Using fourth upgrade building",
|
regions[REGIONS.upgrades_4], "Using fourth upgrade building",
|
||||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False))
|
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False, floating))
|
||||||
regions[REGIONS.upgrades_4].connect(
|
regions[REGIONS.upgrades_4].connect(
|
||||||
regions[REGIONS.upgrades_5], "Using fifth upgrade building",
|
regions[REGIONS.upgrades_5], "Using fifth upgrade building",
|
||||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4,
|
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4,
|
||||||
early_useful == OPTIONS.buildings_5))
|
early_useful == OPTIONS.buildings_5, floating))
|
||||||
|
|
||||||
# Connect Uncolored shapesanity regions to Main
|
# Connect Uncolored shapesanity regions to Main
|
||||||
regions[REGIONS.main].connect(
|
regions[REGIONS.main].connect(
|
||||||
|
|||||||
6
worlds/stardew_valley/archipelago.json
Normal file
6
worlds/stardew_valley/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"game": "Stardew Valley",
|
||||||
|
"authors": ["KaitoKid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"],
|
||||||
|
"minimum_ap_version": "0.6.4",
|
||||||
|
"world_version": "6.0.0"
|
||||||
|
}
|
||||||
@@ -50,7 +50,6 @@ on the Archipelago website to generate a YAML using a graphical interface.
|
|||||||
significantly more difficult with this mod, so it is recommended to choose a lower difficulty than you normally would
|
significantly more difficult with this mod, so it is recommended to choose a lower difficulty than you normally would
|
||||||
play on.
|
play on.
|
||||||
4. Open the world in single player or multiplayer.
|
4. Open the world in single player or multiplayer.
|
||||||
5. When you're ready, open chat, and enter `/apstart` to start the game.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ booster_contents: Dict[str, List[str]] = {
|
|||||||
"Kaiser Glider",
|
"Kaiser Glider",
|
||||||
"Horus the Black Flame Dragon LV6",
|
"Horus the Black Flame Dragon LV6",
|
||||||
"Luster Dragon",
|
"Luster Dragon",
|
||||||
"Luster Dragon #2"
|
"Luster Dragon #2",
|
||||||
"Spear Dragon",
|
"Spear Dragon",
|
||||||
"Armed Dragon LV3",
|
"Armed Dragon LV3",
|
||||||
"Armed Dragon LV5",
|
"Armed Dragon LV5",
|
||||||
@@ -634,7 +634,7 @@ booster_contents: Dict[str, List[str]] = {
|
|||||||
"Mystic Swordsman LV6",
|
"Mystic Swordsman LV6",
|
||||||
"Horus the Black Flame Dragon LV6",
|
"Horus the Black Flame Dragon LV6",
|
||||||
"Horus the Black Flame Dragon LV4",
|
"Horus the Black Flame Dragon LV4",
|
||||||
"Armed Dragon LV3"
|
"Armed Dragon LV3",
|
||||||
"Armed Dragon LV5",
|
"Armed Dragon LV5",
|
||||||
"Silent Swordsman Lv3",
|
"Silent Swordsman Lv3",
|
||||||
"Silent Swordsman Lv5",
|
"Silent Swordsman Lv5",
|
||||||
@@ -750,7 +750,7 @@ booster_contents: Dict[str, List[str]] = {
|
|||||||
"Formation Union",
|
"Formation Union",
|
||||||
"Princess Pikeru",
|
"Princess Pikeru",
|
||||||
"Skull Zoma",
|
"Skull Zoma",
|
||||||
"Metal Reflect Slime"
|
"Metal Reflect Slime",
|
||||||
"Level Up!",
|
"Level Up!",
|
||||||
"Howling Insect",
|
"Howling Insect",
|
||||||
"Tribute Doll",
|
"Tribute Doll",
|
||||||
|
|||||||
@@ -668,7 +668,7 @@ def only_dragon(state, player):
|
|||||||
], player) and (state.count_from_list_unique([
|
], player) and (state.count_from_list_unique([
|
||||||
"Luster Dragon",
|
"Luster Dragon",
|
||||||
"Spear Dragon",
|
"Spear Dragon",
|
||||||
"Cave Dragon"
|
"Cave Dragon",
|
||||||
"Armed Dragon LV3",
|
"Armed Dragon LV3",
|
||||||
"Masked Dragon",
|
"Masked Dragon",
|
||||||
"Twin-Headed Behemoth",
|
"Twin-Headed Behemoth",
|
||||||
|
|||||||
Reference in New Issue
Block a user