Compare commits
52 Commits
NewSoupVi-
...
setup_more
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db85a7f554 | ||
|
|
ecadb301c0 | ||
|
|
360ad7197b | ||
|
|
96ae2235d1 | ||
|
|
37b87e3fde | ||
|
|
5b6714d2c0 | ||
|
|
97c07e91d1 | ||
|
|
7cd7111241 | ||
|
|
4b0306102d | ||
|
|
3f139f2efb | ||
|
|
41a62a1a9e | ||
|
|
8837e617e4 | ||
|
|
2bf410f285 | ||
|
|
04fe43d53a | ||
|
|
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 | ||
|
|
6bf3067a39 | ||
|
|
8d81513842 | ||
|
|
c27be54a4c | ||
|
|
a6316e9991 | ||
|
|
5130eba886 |
13
.github/workflows/build.yml
vendored
@@ -9,21 +9,24 @@ on:
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build.yml'
|
||||
- 'setup.py'
|
||||
- 'requirements.txt'
|
||||
- '*.iss'
|
||||
- 'worlds/*/archipelago.json'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||
# we check the sha256 and require manual intervention if it was updated.
|
||||
APPIMAGETOOL_VERSION: continuous
|
||||
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||
APPIMAGE_RUNTIME_VERSION: continuous
|
||||
APPIMAGE_FORK: 'PopTracker'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-10-19'
|
||||
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||
|
||||
permissions: # permissions required for attestation
|
||||
@@ -139,9 +142,9 @@ jobs:
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
|
||||
11
.github/workflows/release.yml
vendored
@@ -11,9 +11,10 @@ env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||
# we check the sha256 and require manual intervention if it was updated.
|
||||
APPIMAGETOOL_VERSION: continuous
|
||||
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e'
|
||||
APPIMAGE_RUNTIME_VERSION: continuous
|
||||
APPIMAGE_FORK: 'PopTracker'
|
||||
APPIMAGETOOL_VERSION: 'r-2025-10-19'
|
||||
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
|
||||
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
|
||||
|
||||
permissions: # permissions required for attestation
|
||||
@@ -127,9 +128,9 @@ jobs:
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||
wget -nv https://github.com/$APPIMAGE_FORK/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
|
||||
2
.github/workflows/unittests.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest pytest-subtests pytest-xdist
|
||||
pip install -r ci-requirements.txt
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
python Launcher.py --update_settings # make sure host.yaml exists for tests
|
||||
- name: Unittests
|
||||
|
||||
39
Fill.py
@@ -210,43 +210,12 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
_log_fill_progress(name, placed, total)
|
||||
|
||||
if cleanup_required:
|
||||
relevant_locations = multiworld.get_filled_locations(item.player if single_player_placement else None)
|
||||
|
||||
# validate all placements and remove invalid ones
|
||||
cleanup_state = sweep_from_pool(base_state, [], relevant_locations)
|
||||
|
||||
# accessibilty_corrections can clean up any case where locations are unreachable as a result of
|
||||
# a full player's item being on a minimal player's unreachable location.
|
||||
# So, we make a state where we collect all such minimal->full items to check against.
|
||||
changed = False
|
||||
for location in relevant_locations:
|
||||
if location.item is None:
|
||||
continue
|
||||
if location in cleanup_state.locations_checked:
|
||||
continue
|
||||
|
||||
if multiworld.worlds[location.player].options.accessibility == "minimal":
|
||||
if multiworld.worlds[location.item.player].options.accessibility != "minimal":
|
||||
changed |= cleanup_state.collect(location.item, prevent_sweep=True)
|
||||
if changed:
|
||||
cleanup_state.sweep_for_advancements(relevant_locations)
|
||||
|
||||
state = sweep_from_pool(
|
||||
base_state, [], multiworld.get_filled_locations(item.player)
|
||||
if single_player_placement else None)
|
||||
for placement in placements:
|
||||
# If the item's player is minimal, we don't care that it's unreachable.
|
||||
if multiworld.worlds[placement.item.player].options.accessibility == "minimal":
|
||||
continue
|
||||
|
||||
# This item belongs to a full player.
|
||||
# If the location's player is minimal, we don't need to be concerned.
|
||||
# Even if the location is inaccessible, accessibility_corrections will clean this up.
|
||||
if multiworld.worlds[placement.player].options.accessibility == "minimal":
|
||||
continue
|
||||
|
||||
# This is a full player's item on a full player's location.
|
||||
# If this item is unreachable, we have a problem - UNLESS the location is just stuck behind a full player's
|
||||
# item on a minimal player's location. That case will transitively get solved by accessibility_corrections.
|
||||
# This is why we use our special "cleanup_state", not just the maximum exploration state.
|
||||
if not placement.can_reach(cleanup_state):
|
||||
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
|
||||
placement.item.location = None
|
||||
unplaced_items.append(placement.item)
|
||||
placement.item = None
|
||||
|
||||
11
Generate.py
@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
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
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
@@ -57,7 +57,7 @@ def mystery_argparse():
|
||||
parser.add_argument("--spoiler_only", action="store_true",
|
||||
help="Skips generation assertion and multidata, outputting only a spoiler log. "
|
||||
"Intended for debugging and testing purposes.")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.skip_output and args.spoiler_only:
|
||||
parser.error("Cannot mix --skip_output and --spoiler_only")
|
||||
@@ -189,6 +189,11 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
|
||||
yaml[category][key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
elif key == "triggers":
|
||||
if "triggers" not in yaml[category_name]:
|
||||
yaml[category_name][key] = []
|
||||
for trigger in option:
|
||||
yaml[category_name][key].append(trigger)
|
||||
else:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
@@ -385,6 +390,8 @@ def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
|
||||
if options[option_key].supports_weighting:
|
||||
return get_choice(option_key, category_dict)
|
||||
return category_dict[option_key]
|
||||
if option_key == "triggers":
|
||||
return category_dict[option_key]
|
||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
||||
|
||||
|
||||
|
||||
@@ -1200,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]
|
||||
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:
|
||||
status = HintStatus.HINT_FOUND
|
||||
elif status is None:
|
||||
hint_status = HintStatus.HINT_FOUND
|
||||
elif hint_status is None:
|
||||
if item_flags & ItemClassification.trap:
|
||||
status = HintStatus.HINT_AVOID
|
||||
hint_status = HintStatus.HINT_AVOID
|
||||
else:
|
||||
status = HintStatus.HINT_PRIORITY
|
||||
hint_status = HintStatus.HINT_PRIORITY
|
||||
|
||||
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
|
||||
|
||||
40
Utils.py
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import typing
|
||||
import builtins
|
||||
@@ -477,7 +478,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
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
|
||||
# Forbid everything else.
|
||||
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):
|
||||
return False
|
||||
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 os
|
||||
import socket
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
from flask import Flask
|
||||
@@ -61,20 +62,21 @@ cache = Cache()
|
||||
Compress(app)
|
||||
|
||||
|
||||
def to_python(value):
|
||||
def to_python(value: str) -> uuid.UUID:
|
||||
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')
|
||||
|
||||
|
||||
class B64UUIDConverter(BaseConverter):
|
||||
|
||||
def to_python(self, value):
|
||||
def to_python(self, value: str) -> uuid.UUID:
|
||||
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)
|
||||
|
||||
|
||||
@@ -84,7 +86,7 @@ app.jinja_env.filters["suuid"] = to_url
|
||||
app.jinja_env.filters["title_sorted"] = title_sorted
|
||||
|
||||
|
||||
def register():
|
||||
def register() -> None:
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
import importlib
|
||||
|
||||
@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
|
||||
_stop_event = Event()
|
||||
|
||||
|
||||
def stop():
|
||||
def stop() -> None:
|
||||
"""Stops previously launched threads"""
|
||||
global _stop_event
|
||||
stop_event = _stop_event
|
||||
@@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException):
|
||||
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
|
||||
|
||||
setproctitle(f"Generator ({sid})")
|
||||
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||
setproctitle(f"Generator (idle)")
|
||||
return res
|
||||
try:
|
||||
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
||||
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:
|
||||
meta = json.loads(generation.meta)
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(_mp_gen_game, (options,),
|
||||
{"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
pool.apply_async(
|
||||
_mp_gen_game,
|
||||
(options,),
|
||||
{
|
||||
"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner,
|
||||
"timeout": timeout,
|
||||
},
|
||||
handle_generation_success,
|
||||
handle_generation_failure,
|
||||
)
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
@@ -135,6 +149,7 @@ def autogen(config: dict):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||
job_time = config["JOB_TIME"]
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
@@ -145,7 +160,7 @@ def autogen(config: dict):
|
||||
if sid:
|
||||
generation.delete()
|
||||
else:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
|
||||
commit()
|
||||
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
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
except AlreadyRunningException:
|
||||
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 Generate import PlandoOptions, handle_name, mystery_argparse
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__, restricted_dumps
|
||||
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
||||
from WebHostLib import app
|
||||
from settings import ServerOptions, GeneratorOptions
|
||||
from .check import get_yaml_data, roll_options
|
||||
@@ -107,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||
else:
|
||||
try:
|
||||
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:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
@@ -118,7 +118,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||
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:
|
||||
meta = {}
|
||||
|
||||
@@ -137,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))
|
||||
|
||||
args = mystery_argparse()
|
||||
args = mystery_argparse([]) # Just to set up the Namespace with defaults
|
||||
args.multi = playercount
|
||||
args.seed = seed
|
||||
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
@@ -172,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"])
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
return thread.result(app.config["JOB_TIME"])
|
||||
return thread.result(timeout)
|
||||
except concurrent.futures.TimeoutError as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -189,6 +190,9 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
||||
format_exception(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
# don't update db, retry next time
|
||||
raise
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -200,6 +204,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
|
||||
gen.meta = json.dumps(meta)
|
||||
commit()
|
||||
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>')
|
||||
|
||||
90
WebHostLib/markdown.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
import mistune
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ImgUrlRewriteInlineParser",
|
||||
'render_markdown',
|
||||
]
|
||||
|
||||
|
||||
class ImgUrlRewriteInlineParser(mistune.InlineParser):
|
||||
relative_url_base: str
|
||||
|
||||
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
|
||||
super().__init__(hard_wrap)
|
||||
self.relative_url_base = relative_url_base
|
||||
|
||||
@staticmethod
|
||||
def _find_game_name_by_folder_name(name: str) -> str | None:
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
for world_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if world_type.__module__ == f"worlds.{name}":
|
||||
return world_name
|
||||
return None
|
||||
|
||||
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
|
||||
res = super().parse_link(m, state)
|
||||
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
|
||||
image_token = state.tokens[-1]
|
||||
url: str = image_token["attrs"]["url"]
|
||||
if not url.startswith("/") and not "://" in url:
|
||||
# replace relative URL to another world's doc folder with the webhost folder layout
|
||||
if url.startswith("../../") and "/docs/" in self.relative_url_base:
|
||||
parts = url.split("/", 4)
|
||||
if parts[2] != ".." and parts[3] == "docs":
|
||||
game_name = self._find_game_name_by_folder_name(parts[2])
|
||||
if game_name is not None:
|
||||
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
|
||||
# change relative URL to point to deployment folder
|
||||
url = f"{self.relative_url_base}/{url}"
|
||||
image_token['attrs']['url'] = url
|
||||
return res
|
||||
|
||||
|
||||
def render_markdown(path: str, img_url_base: str | None = None) -> str:
|
||||
markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=[
|
||||
"strikethrough",
|
||||
"footnotes",
|
||||
"table",
|
||||
"speedup",
|
||||
],
|
||||
)
|
||||
|
||||
heading_id_count: Counter[str] = Counter()
|
||||
|
||||
def heading_id(text: str) -> str:
|
||||
nonlocal heading_id_count
|
||||
|
||||
# there is no good way to do this without regex
|
||||
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
||||
n = heading_id_count[s]
|
||||
heading_id_count[s] += 1
|
||||
if n > 0:
|
||||
s += f"-{n}"
|
||||
return s
|
||||
|
||||
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
||||
for tok in state.tokens:
|
||||
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
||||
text = tok["text"]
|
||||
assert isinstance(text, str)
|
||||
unique_id = heading_id(text)
|
||||
tok["attrs"]["id"] = unique_id
|
||||
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
||||
|
||||
markdown.before_render_hooks.append(id_hook)
|
||||
if img_url_base:
|
||||
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
|
||||
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
document = f.read()
|
||||
html = markdown(document)
|
||||
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
|
||||
return html
|
||||
@@ -9,6 +9,7 @@ from werkzeug.utils import secure_filename
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister, World
|
||||
from . import app, cache
|
||||
from .markdown import render_markdown
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
from Utils import title_sorted
|
||||
|
||||
@@ -27,49 +28,6 @@ def get_visible_worlds() -> dict[str, type(World)]:
|
||||
return worlds
|
||||
|
||||
|
||||
def render_markdown(path: str) -> str:
|
||||
import mistune
|
||||
from collections import Counter
|
||||
|
||||
markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=[
|
||||
"strikethrough",
|
||||
"footnotes",
|
||||
"table",
|
||||
"speedup",
|
||||
],
|
||||
)
|
||||
|
||||
heading_id_count: Counter[str] = Counter()
|
||||
|
||||
def heading_id(text: str) -> str:
|
||||
nonlocal heading_id_count
|
||||
import re # there is no good way to do this without regex
|
||||
|
||||
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
||||
n = heading_id_count[s]
|
||||
heading_id_count[s] += 1
|
||||
if n > 0:
|
||||
s += f"-{n}"
|
||||
return s
|
||||
|
||||
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
||||
for tok in state.tokens:
|
||||
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
||||
text = tok["text"]
|
||||
assert isinstance(text, str)
|
||||
unique_id = heading_id(text)
|
||||
tok["attrs"]["id"] = unique_id
|
||||
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
||||
|
||||
markdown.before_render_hooks.append(id_hook)
|
||||
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
document = f.read()
|
||||
return markdown(document)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
@@ -91,10 +49,9 @@ def game_info(game, lang):
|
||||
theme = get_world_theme(game)
|
||||
secure_game_name = secure_filename(game)
|
||||
lang = secure_filename(lang)
|
||||
document = render_markdown(os.path.join(
|
||||
app.static_folder, "generated", "docs",
|
||||
secure_game_name, f"{lang}_{secure_game_name}.md"
|
||||
))
|
||||
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
||||
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
||||
document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title=f"{game} Guide",
|
||||
@@ -119,10 +76,9 @@ def tutorial(game: str, file: str):
|
||||
theme = get_world_theme(game)
|
||||
secure_game_name = secure_filename(game)
|
||||
file = secure_filename(file)
|
||||
document = render_markdown(os.path.join(
|
||||
app.static_folder, "generated", "docs",
|
||||
secure_game_name, file+".md"
|
||||
))
|
||||
file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
|
||||
file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
|
||||
document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title=f"{game} Guide",
|
||||
|
||||
@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
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,
|
||||
'file_insertion_enabled': False,
|
||||
'output_encoding': 'unicode'
|
||||
|
||||
@@ -4,9 +4,10 @@ pony>=0.7.19; python_version <= '3.12'
|
||||
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
|
||||
waitress>=3.0.2
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.17
|
||||
Flask-Compress==1.18 # pkg_resources can't resolve the "backports.zstd" dependency of >1.18, breaking ModuleUpdate.py
|
||||
Flask-Limiter>=3.12
|
||||
bokeh>=3.6.3
|
||||
markupsafe>=3.0.2
|
||||
setproctitle>=1.3.5
|
||||
mistune>=3.1.3
|
||||
docutils>=0.22.2
|
||||
|
||||
3
ci-requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
pytest>=8.4.2,<9 # pytest 9.0.0 is broken for our CI
|
||||
pytest-xdist>=3.8.0
|
||||
pytest-subtests>=0.15.0 # will not be required anymore once we upgrade to pytest 9.x
|
||||
@@ -8,3 +8,7 @@ SELFLAUNCH: false
|
||||
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
|
||||
# Set as your local IP (192.168.x.x) to serve over LAN.
|
||||
HOST_ADDRESS: localhost
|
||||
|
||||
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
|
||||
# the proprietary assets in WebHostLib
|
||||
#ASSET_RIGHTS: false
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
# apworld Specification
|
||||
# APWorld Specification
|
||||
|
||||
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.
|
||||
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`
|
||||
file into the worlds folder.
|
||||
## .apworld File Format
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
## File Format
|
||||
|
||||
apworld files are zip archives, all lower case, with the file ending `.apworld`.
|
||||
`.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 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 about the apworld is defined in an `archipelago.json` file inside the zip archive.
|
||||
The current format version has at minimum:
|
||||
Metadata about the APWorld is defined in an `archipelago.json` file.
|
||||
|
||||
If the APWorld is a folder, the only required field is "game":
|
||||
```json
|
||||
{
|
||||
"version": 7,
|
||||
"compatible_version": 7,
|
||||
"game": "Game Name"
|
||||
"game": "Game Name"
|
||||
}
|
||||
```
|
||||
|
||||
The `version` and `compatible_version` fields refer to Archipelago's internal file packaging scheme
|
||||
and 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.
|
||||
On the other hand, the `game` field should be present in the world folder's manifest file before packaging.
|
||||
|
||||
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.
|
||||
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`,
|
||||
@@ -86,7 +86,7 @@ The zip can contain arbitrary files in addition what was specified above.
|
||||
|
||||
## 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
|
||||
`from worlds.AutoWorld import World`
|
||||
|
||||
@@ -647,6 +647,16 @@ class Version(NamedTuple):
|
||||
build: int
|
||||
```
|
||||
|
||||
If constructing version information as a dict for a custom client rather than as a NamedTuple built into the CommonClient, you must add the `class` key to allow Archipelago to compare version support.
|
||||
```
|
||||
"version": {
|
||||
"class": "Version",
|
||||
"build": X,
|
||||
"major": Y,
|
||||
"minor": Z
|
||||
}
|
||||
```
|
||||
|
||||
### SlotType
|
||||
An enum representing the nature of a slot.
|
||||
|
||||
|
||||
11
kvui.py
@@ -34,6 +34,17 @@ from kivy.config import Config
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Config.set("kivy", "exit_on_escape", "0")
|
||||
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 kivy.core.window import Window
|
||||
from kivy.core.clipboard import Clipboard
|
||||
|
||||
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.
|
||||
]
|
||||
31
setup.py
@@ -63,18 +63,11 @@ from Cython.Build import cythonize
|
||||
|
||||
|
||||
non_apworlds: set[str] = {
|
||||
"A Link to the Past",
|
||||
"Adventure",
|
||||
"Archipelago",
|
||||
"Lufia II Ancient Cave",
|
||||
"Meritous",
|
||||
"Ocarina of Time",
|
||||
"Overcooked! 2",
|
||||
"Raft",
|
||||
"Sudoku",
|
||||
"Super Mario 64",
|
||||
"VVVVVV",
|
||||
"Wargroove",
|
||||
"Archipelago", # needs a way to specify load order
|
||||
"Final Fantasy", # loads json files badly
|
||||
"Lufia II Ancient Cave", # loads basepatch badly
|
||||
"Ocarina of Time", # has executables in folder
|
||||
"Raft", # loads json files badly
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +139,16 @@ def download_SNI() -> None:
|
||||
|
||||
signtool: str | None = None
|
||||
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()
|
||||
if b"status=OK\n" in html:
|
||||
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
||||
@@ -381,7 +383,8 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||
world_directory = self.libfolder / "worlds" / file_name
|
||||
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, (
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from Options import ItemLinks, Choice
|
||||
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Utils import restricted_dumps
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
|
||||
for link in item_links.values():
|
||||
self.assertEqual(link.value[0], item_link_group[0])
|
||||
|
||||
def test_pickle_dumps(self):
|
||||
"""Test options can be pickled into database for WebHost generation"""
|
||||
def test_pickle_dumps_default(self):
|
||||
"""Test that default option values can be pickled into database for WebHost generation"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
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))
|
||||
if issubclass(option, Choice) and option.default in option.name_lookup:
|
||||
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
@@ -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,
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
@@ -11,7 +12,7 @@ from test.hosting.client import Client
|
||||
from test.hosting.generate import generate_local
|
||||
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,
|
||||
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
|
||||
|
||||
failure = False
|
||||
@@ -56,35 +57,62 @@ else:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
warnings.simplefilter("ignore", ResourceWarning)
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
|
||||
spacer = '=' * 80
|
||||
|
||||
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"]]
|
||||
p1_games = []
|
||||
data_paths = []
|
||||
rooms = []
|
||||
p1_games: list[str] = []
|
||||
data_paths: list[Path | None] = []
|
||||
rooms: list[str] = []
|
||||
multidata: Path | None
|
||||
|
||||
copy_world("VVVVVV", "Temp World")
|
||||
try:
|
||||
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)
|
||||
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
|
||||
p1_games.append(games[0])
|
||||
data_paths.append(multidata)
|
||||
p1_games.append(games[0])
|
||||
finally:
|
||||
delete_world("Temp World")
|
||||
|
||||
webapp = get_app(tempdir)
|
||||
webhost_client = webapp.test_client()
|
||||
|
||||
for n, multidata in enumerate(data_paths, 1):
|
||||
assert multidata
|
||||
seed = upload_multidata(webhost_client, multidata)
|
||||
print(f"Uploaded [{n}] {multidata} as {seed}\n")
|
||||
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)
|
||||
|
||||
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):
|
||||
involved_games = {"Archipelago"} | set(multi_games)
|
||||
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")
|
||||
prev_host_adr: str
|
||||
with WebHostServeGame(webhost_client, room) as host:
|
||||
sleep(.1) # wait for the server to fully start before doing anything
|
||||
prev_host_adr = host.address
|
||||
with Client(host.address, game, "Player1") as client:
|
||||
web_data_packages = client.games_packages
|
||||
@@ -134,6 +141,7 @@ if __name__ == "__main__":
|
||||
autohost(webapp.config) # this will spin the room right up again
|
||||
sleep(1) # make log less annoying
|
||||
# if saving failed, the next iteration will fail below
|
||||
sleep(2) # work around issue #5571
|
||||
|
||||
# verify server shut down
|
||||
try:
|
||||
@@ -156,6 +164,31 @@ if __name__ == "__main__":
|
||||
"customserver did not load or save correctly during/after "
|
||||
+ ("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
|
||||
expect_equal(local_data_packages, web_data_packages,
|
||||
"customserver datapackage differs from MultiServer")
|
||||
@@ -176,10 +209,12 @@ if __name__ == "__main__":
|
||||
print(f"Restoring multidata for {room}")
|
||||
set_multidata_for_room(webhost_client, room, old_data)
|
||||
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:
|
||||
assert_equal(len(client.checked_locations), 2,
|
||||
"Save was destroyed during exception in customserver")
|
||||
print("Save file is not busted 🥳")
|
||||
sleep(2) # work around issue #5571
|
||||
|
||||
finally:
|
||||
print("Stopping autohost")
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
from typing import TYPE_CHECKING, Iterable, Optional, cast
|
||||
|
||||
from WebHostLib import to_python
|
||||
|
||||
@@ -10,6 +14,7 @@ if TYPE_CHECKING:
|
||||
|
||||
__all__ = [
|
||||
"get_app",
|
||||
"generate_remote",
|
||||
"upload_multidata",
|
||||
"create_room",
|
||||
"start_room",
|
||||
@@ -17,6 +22,7 @@ __all__ = [
|
||||
"set_room_timeout",
|
||||
"get_multidata_for_room",
|
||||
"set_multidata_for_room",
|
||||
"stop_autogen",
|
||||
"stop_autohost",
|
||||
]
|
||||
|
||||
@@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask":
|
||||
"TESTING": True,
|
||||
"HOST_ADDRESS": "localhost",
|
||||
"HOSTERS": 1,
|
||||
"GENERATORS": 1,
|
||||
"JOB_THRESHOLD": 1,
|
||||
})
|
||||
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:
|
||||
response = app_client.post("/uploads", data={
|
||||
"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
|
||||
|
||||
|
||||
def stop_autohost(graceful: bool = True) -> None:
|
||||
def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
|
||||
import os
|
||||
import signal
|
||||
|
||||
@@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None:
|
||||
|
||||
stop()
|
||||
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:
|
||||
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
|
||||
else:
|
||||
proc.kill()
|
||||
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:
|
||||
proc.kill()
|
||||
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:
|
||||
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"
|
||||
if '"' in dst or "\\" in dst: # easier to reject than to escape
|
||||
|
||||
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)
|
||||
78
test/webhost/test_markdown.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
import unittest
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
|
||||
from WebHostLib.markdown import ImgUrlRewriteInlineParser, render_markdown
|
||||
|
||||
|
||||
class ImgUrlRewriteTest(unittest.TestCase):
|
||||
markdown: Markdown
|
||||
base_url = "/static/generated/docs/some_game"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.markdown = Markdown(
|
||||
renderer=HTMLRenderer(escape=False),
|
||||
inline=ImgUrlRewriteInlineParser(self.base_url),
|
||||
)
|
||||
|
||||
def test_relative_img_rewrite(self) -> None:
|
||||
html = self.markdown("")
|
||||
self.assertIn(f'src="{self.base_url}/image.png"', html)
|
||||
|
||||
def test_absolute_img_no_rewrite(self) -> None:
|
||||
html = self.markdown("")
|
||||
self.assertIn(f'src="/image.png"', html)
|
||||
self.assertNotIn(self.base_url, html)
|
||||
|
||||
def test_remote_img_no_rewrite(self) -> None:
|
||||
html = self.markdown("")
|
||||
self.assertIn(f'src="https://example.com/image.png"', html)
|
||||
self.assertNotIn(self.base_url, html)
|
||||
|
||||
def test_relative_link_no_rewrite(self) -> None:
|
||||
# The parser is only supposed to update images, not links.
|
||||
html = self.markdown("[Link](image.png)")
|
||||
self.assertIn(f'href="image.png"', html)
|
||||
self.assertNotIn(self.base_url, html)
|
||||
|
||||
def test_absolute_link_no_rewrite(self) -> None:
|
||||
html = self.markdown("[Link](/image.png)")
|
||||
self.assertIn(f'href="/image.png"', html)
|
||||
self.assertNotIn(self.base_url, html)
|
||||
|
||||
def test_auto_link_no_rewrite(self) -> None:
|
||||
html = self.markdown("<https://example.com/image.png>")
|
||||
self.assertIn(f'href="https://example.com/image.png"', html)
|
||||
self.assertNotIn(self.base_url, html)
|
||||
|
||||
def test_relative_img_to_other_game(self) -> None:
|
||||
html = self.markdown("")
|
||||
self.assertIn(f'src="{self.base_url}/../Archipelago/image.png"', html)
|
||||
|
||||
|
||||
class RenderMarkdownTest(unittest.TestCase):
|
||||
"""Tests that render_markdown does the right thing."""
|
||||
base_url = "/static/generated/docs/some_game"
|
||||
|
||||
def test_relative_img_rewrite(self) -> None:
|
||||
f = NamedTemporaryFile(delete=False)
|
||||
try:
|
||||
f.write("".encode("utf-8"))
|
||||
f.close()
|
||||
html = render_markdown(f.name, self.base_url)
|
||||
self.assertIn(f'src="{self.base_url}/image.png"', html)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
|
||||
def test_no_img_rewrite(self) -> None:
|
||||
f = NamedTemporaryFile(delete=False)
|
||||
try:
|
||||
f.write("".encode("utf-8"))
|
||||
f.close()
|
||||
html = render_markdown(f.name)
|
||||
self.assertIn(f'src="image.png"', html)
|
||||
self.assertNotIn(self.base_url, html)
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
@@ -271,7 +271,8 @@ if not is_frozen():
|
||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||
world_directory = os.path.join("worlds", file_name)
|
||||
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, (
|
||||
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 file in filenames:
|
||||
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
|
||||
if manifest:
|
||||
break
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import Utils
|
||||
import websockets
|
||||
import functools
|
||||
@@ -208,6 +210,9 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
|
||||
if not ctx.is_proxy_connected():
|
||||
break
|
||||
|
||||
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
|
||||
msg["data"]["time"] = time.time()
|
||||
|
||||
await ctx.send_msgs([msg])
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -243,7 +243,7 @@ guaranteed_first_acts = [
|
||||
"Time Rift - Mafia of Cooks",
|
||||
"Time Rift - Dead Bird Studio",
|
||||
"Time Rift - Sleepy Subcon",
|
||||
"Time Rift - Alpine Skyline"
|
||||
"Time Rift - Alpine Skyline",
|
||||
"Time Rift - Tour",
|
||||
"Time Rift - Rumbi Factory",
|
||||
]
|
||||
|
||||
@@ -88,9 +88,8 @@ You only have to do these steps once.
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -88,9 +88,8 @@ Sólo hay que seguir estos pasos una vez.
|
||||
1. Comienza en la pantalla del menú principal de RetroArch.
|
||||
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
||||
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
|
||||
el Puerto de comandos de red.
|
||||
|
||||

|
||||
el Puerto de comandos de red. \
|
||||

|
||||
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
||||
SFC (bsnes-mercury Performance)".
|
||||
|
||||
|
||||
@@ -89,9 +89,8 @@ Vous n'avez qu'à faire ces étapes qu'une fois.
|
||||
1. Entrez dans le menu principal RetroArch
|
||||
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
|
||||
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
|
||||
Port des commandes réseau à 555355.
|
||||
|
||||

|
||||
Port des commandes réseau à 555355. \
|
||||

|
||||
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
|
||||
sélectionnez le.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -13,7 +13,7 @@ All scraps or any collectable item on the ground (except from Loot Crates) and i
|
||||
Beating the evil train from Hell named "Charles".
|
||||
|
||||
## How is the game managed in Nightmare mode?
|
||||
At death, the player has to restart a brand-new game, giving him the choice to stay under the Nightmare mode or continuing with the Normal mode if considered too hard.
|
||||
At death, the player has to restart a brand-new game, giving them the choice to stay under the Nightmare Mode or continuing with the Classic Mode if considered too hard.
|
||||
In this case, all collected items will be redistributed in the inventory and the missions states will be kept.
|
||||
The Deathlink is not implemented yet. When this option will be available, a choice will be provided to:
|
||||
* Disable the Deathlink
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
## Où est la page d'options ?
|
||||
La [page d'options du joueur pour ce jeu](../player-options) contient toutes les options pour configurer et exporter un fichier de configuration yaml.
|
||||
|
||||
## Qu'est ce que la randomisation fait au jeu ?
|
||||
Tous les débrits ou n'importe quel objet ramassable au sol (excepté les Caisses à Butin) et objets reçus par les missions de PNJs sont considérés comme emplacements à vérifier.
|
||||
## Qu'est-ce que la randomisation fait au jeu ?
|
||||
Tous les débris ou n'importe quel objet ramassable au sol (excepté les Caisses à Butin) et objets reçus par les missions de PNJs sont considérés comme emplacements à vérifier.
|
||||
|
||||
## Quel est le but de Choo-Choo Charles lorsqu'il est randomisé ?
|
||||
Vaincre le train démoniaque de l'Enfer nommé "Charles".
|
||||
|
||||
## Comment le jeu est-il géré en mode Nightmare ?
|
||||
À sa mort, le joueur doit relancer une toute nouvelle partie, lui donnant la possisilité de rester en mode Nightmare ou de poursuivre la partie en mode Normal s'il considère la partie trop difficile.
|
||||
## Comment le jeu est-il géré en Mode Cauchemar ?
|
||||
À sa mort, le joueur doit relancer une toute nouvelle partie, lui donnant la possibilité de rester en Mode Cauchemar ou de poursuivre la partie en Mode Classique s'il considère la partie trop difficile.
|
||||
Dans ce cas, tous les objets collectés seront redistribués dans l'inventaire et les états des missions seront conservés.
|
||||
Le Deathlink n'est pas implémenté pour l'instant. Lorsque cette option sera disponible, un choix sera fourni pour :
|
||||
* Désactiver le Deathlink
|
||||
@@ -18,7 +18,7 @@ Le Deathlink n'est pas implémenté pour l'instant. Lorsque cette option sera di
|
||||
* Activer le Deathlink strict avec suppression de la sauvegarde lorsqu'un évènement Deathlink est reçu
|
||||
|
||||
## À quoi ressemble un objet d'un autre monde dans Choo-Choo Charles ?
|
||||
Les apparances des objets sont conservés.
|
||||
Les apparences des objets sont conservées.
|
||||
Tout indice qui ne peut pas être représenté normalement dans le jeu est remplacé par l'Easter Egg "DeadDuck" miniaturisé qui peut être vu en dehors des limites murales physiques du jeu original.
|
||||
|
||||
## Comment le joueur est-il informé par une transmission d'objet et des indices ?
|
||||
@@ -29,7 +29,7 @@ La même méthode est utilisée pour les indices.
|
||||
Non, ceci est un travail en cours.
|
||||
Les options suivantes seront possibles une fois les implémentations disponibles :
|
||||
|
||||
À n'importe quel moment, le joueur peu appuyer sur l'une des touches suivantes pour afficher la console dans le jeu :
|
||||
À n'importe quel moment, le joueur peut appuyer sur l'une des touches suivantes pour afficher la console dans le jeu :
|
||||
* "~" (qwerty)
|
||||
* "²" (azerty)
|
||||
* "F10"
|
||||
|
||||
@@ -27,7 +27,7 @@ The [Player Options page](/games/Choo-Choo%20Charles/player-options) allows to c
|
||||
Before playing, it is highly recommended to check out the **[Known Issues](setup_en#known-issues)** section
|
||||
* The game console must be opened to type Archipelago commands, press "F10" key or "`" (or "~") key in querty ("²" key in azerty)
|
||||
* Type ``/connect <IP> <PlayerName>`` with \<IP\> and \<PlayerName\> found on the hosting Archipelago web page in the form ``archipelago.gg:XXXXX`` and ``CCCharles``
|
||||
* Disconnection is automatic at game closure but can be manually done with ``/disconnect``
|
||||
* Disconnection is automatic at game closure, but can be manually done with ``/disconnect``
|
||||
|
||||
## Hosting a MultiWorld or Single-Player Game
|
||||
See the [Mod Download](setup_en#mod-download) section to get the **cccharles.apworld** file.
|
||||
|
||||
@@ -4,7 +4,7 @@ Cette page est un guide simplifié de la [page du Mod Randomiseur Multiworld de
|
||||
## Exigences et Logiciels Nécessaires
|
||||
* Un ordinateur utilisant Windows (le Mod n'est pas utilisable sous Linux ou Mac)
|
||||
* [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
* Une copie légale du jeu original Choo-Choo Charles (peut être trouvé sur [Steam](https://store.steampowered.com/app/1766740/ChooChoo_Charles/)
|
||||
* Une copie légale du jeu original Choo-Choo Charles (peut être trouvé sur [Steam](https://store.steampowered.com/app/1766740/ChooChoo_Charles/))
|
||||
|
||||
## Installation du Mod pour jouer
|
||||
### Téléchargement du Mod
|
||||
@@ -27,7 +27,7 @@ La [page d'Options Joueur](/games/Choo-Choo%20Charles/player-options) permet de
|
||||
Avant de jouer, il est fortement recommandé de consulter la section **[Problèmes Connus](setup_fr#probl%C3%A8mes-connus)**.
|
||||
* La console du jeu doit être ouverte pour taper des commandes Archipelago, appuyer sur la touche "F10" ou "`" (ou "~") en querty (touche "²" en azerty)
|
||||
* Taper ``/connect <IP> <NomDuJoueur>`` avec \<IP\> et \<NomDuJoueur\> trouvés sur la page web d'hébergement Archipelago sous la forme ``archipelago.gg:XXXXX`` et ``CCCharles``
|
||||
* La déconnexion est automatique à la fermeture du jeu mais peut être faite manuellement avec ``/disconnect``
|
||||
* La déconnexion est automatique à la fermeture du jeu, mais peut être faite manuellement avec ``/disconnect``
|
||||
|
||||
## Héberger une partie MultiWorld ou un Seul Joueur
|
||||
Voir la section [Téléchargement du Mod](setup_fr#téléchargement-du-mod) pour récupérer le fichier **cccharles.apworld**.
|
||||
@@ -39,7 +39,7 @@ Suivre ces étapes pour héberger une session multijoueur à distance ou locale
|
||||
2. Placer le **CCCharles.yaml** dans **Archipelago/Players/** avec le YAML de chaque joueur à héberger
|
||||
3. Exécuter le lanceur Archipelago et cliquer sur "Generate" pour configurer une partie avec les YAML dans **Archipelago/output/**
|
||||
4. Pour une session multijoueur, aller à la [page Archipelago HOST GAME](https://archipelago.gg/uploads)
|
||||
5. Cliquer sur "Upload File" et selectionner le **AP_\<seed\>.zip** généré dans **Archipelago/output/**
|
||||
5. Cliquer sur "Upload File" et sélectionner le **AP_\<seed\>.zip** généré dans **Archipelago/output/**
|
||||
6. Envoyer la page de la partie générée à chaque joueur
|
||||
|
||||
Pour une session locale à un seul joueur, cliquer sur "Host" dans le lanceur Archipelago en utilisant **AP_\<seed\>.zip** généré dans **Archipelago/output/**
|
||||
|
||||
@@ -20,6 +20,7 @@ class CivVIBoostData:
|
||||
Prereq: List[str]
|
||||
PrereqRequiredCount: int
|
||||
Classification: str
|
||||
EraRequired: bool = False
|
||||
|
||||
|
||||
class GoodyHutRewardData(TypedDict):
|
||||
|
||||
@@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
|
||||
location = CivVILocationData(
|
||||
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
|
||||
|
||||
return era_locations
|
||||
|
||||
@@ -210,8 +210,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_SQUARE_RIGGING",
|
||||
"ERA_RENAISSANCE",
|
||||
["TECH_GUNPOWDER"],
|
||||
1,
|
||||
["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -252,15 +252,15 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_BALLISTICS",
|
||||
"ERA_INDUSTRIAL",
|
||||
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"],
|
||||
2,
|
||||
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_MILITARY_SCIENCE",
|
||||
"ERA_INDUSTRIAL",
|
||||
["TECH_STIRRUPS"],
|
||||
1,
|
||||
["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -301,8 +301,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_REPLACEABLE_PARTS",
|
||||
"ERA_MODERN",
|
||||
["TECH_MILITARY_SCIENCE"],
|
||||
1,
|
||||
["TECH_MILITARY_SCIENCE", "TECH_MINING"],
|
||||
2,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -343,8 +343,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_ADVANCED_FLIGHT",
|
||||
"ERA_ATOMIC",
|
||||
["TECH_FLIGHT"],
|
||||
1,
|
||||
["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -436,8 +436,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_COMPOSITES",
|
||||
"ERA_INFORMATION",
|
||||
["TECH_COMBUSTION"],
|
||||
1,
|
||||
["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -470,7 +470,7 @@ boosts: List[CivVIBoostData] = [
|
||||
"TECH_ELECTRICITY",
|
||||
"TECH_NUCLEAR_FISSION",
|
||||
],
|
||||
1,
|
||||
4,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -651,10 +651,11 @@ boosts: List[CivVIBoostData] = [
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_FEUDALISM",
|
||||
"ERA_MEDIEVAL",
|
||||
"ERA_CLASSICAL",
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True,
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_CIVIL_SERVICE",
|
||||
@@ -662,6 +663,7 @@ boosts: List[CivVIBoostData] = [
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True,
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_MERCENARIES",
|
||||
@@ -790,6 +792,7 @@ boosts: List[CivVIBoostData] = [
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_CONSERVATION",
|
||||
@@ -885,6 +888,7 @@ boosts: List[CivVIBoostData] = [
|
||||
["TECH_ROCKETRY"],
|
||||
1,
|
||||
"DEFAULT",
|
||||
True
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_GLOBALIZATION",
|
||||
|
||||
@@ -105,3 +105,78 @@ class TestBoostsanityExcluded(CivVITestBase):
|
||||
if "BOOST" in location.name:
|
||||
found_locations += 1
|
||||
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"))
|
||||
|
||||
@@ -609,8 +609,8 @@ class CV64PatchExtensions(APPatchExtension):
|
||||
|
||||
# Shimmy speed increase hack
|
||||
if options["increase_shimmy_speed"]:
|
||||
rom_data.write_int32(0x97EB4, 0x803FE9F0)
|
||||
rom_data.write_int32s(0xBFE9F0, patches.shimmy_speed_modifier)
|
||||
rom_data.write_int32(0x97EB4, 0x803FEA20)
|
||||
rom_data.write_int32s(0xBFEA20, patches.shimmy_speed_modifier)
|
||||
|
||||
# Disable landing fall damage
|
||||
if options["fall_guard"]:
|
||||
|
||||
@@ -110,7 +110,7 @@ def get_item_counts(world: "CVCotMWorld") -> Dict[ItemClassification, Dict[str,
|
||||
|
||||
# If Halve DSS Cards Placed is on, determine which cards we will exclude here.
|
||||
if world.options.halve_dss_cards_placed:
|
||||
excluded_cards = list(ACTION_CARDS.union(ATTRIBUTE_CARDS))
|
||||
excluded_cards = sorted(ACTION_CARDS.union(ATTRIBUTE_CARDS))
|
||||
|
||||
has_freeze_action = False
|
||||
has_freeze_attr = False
|
||||
|
||||
@@ -111,9 +111,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import csv
|
||||
import enum
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from functools import reduce
|
||||
from random import Random
|
||||
from typing import Dict, List, Set
|
||||
|
||||
@@ -61,7 +62,7 @@ def load_item_csv():
|
||||
item_reader = csv.DictReader(file)
|
||||
for item in item_reader:
|
||||
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}
|
||||
items.append(ItemData(id, item["name"], classification, groups))
|
||||
return items
|
||||
|
||||
@@ -22,7 +22,7 @@ id,name,classification,groups
|
||||
20,Wall Jump Pack,progression,"DLC,Freemium"
|
||||
21,Health Bar Pack,useful,"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"
|
||||
25,Canadian Dialog Pack,filler,"DLC,Freemium"
|
||||
26,DLC NPC Pack,progression,"DLC,Freemium"
|
||||
|
||||
|
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 493 KiB |
@@ -92,7 +92,7 @@ appropriate to your operating system, and extract the folder to a convenient loc
|
||||
Archipelago is to place the extracted game folder into the `Archipelago` directory and rename it to just be "Factorio".
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`. You
|
||||
will be asked to log in to your Factorio account using the same credentials you used on Factorio's website. After you
|
||||
@@ -122,7 +122,7 @@ This allows you to host your own Factorio game.
|
||||
Archipelago if you chose to include it during the installation process.
|
||||
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
|
||||
|
||||

|
||||

|
||||
|
||||
7. Launch your Factorio Client
|
||||
8. Click on "Multiplayer" in the main menu
|
||||
|
||||
@@ -16,6 +16,7 @@ logger = logging.getLogger("Client")
|
||||
|
||||
|
||||
rom_name_location = 0x07FFE3
|
||||
player_name_location = 0x07BCC0
|
||||
locations_array_start = 0x200
|
||||
locations_array_length = 0x100
|
||||
items_obtained = 0x03
|
||||
@@ -111,6 +112,12 @@ class FF1Client(BizHawkClient):
|
||||
|
||||
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:
|
||||
if ctx.server is None:
|
||||
return
|
||||
@@ -204,7 +211,7 @@ class FF1Client(BizHawkClient):
|
||||
write_list.append((location, [0], self.sram))
|
||||
elif current_item_name in no_overworld_items:
|
||||
if current_item_name == "Sigil":
|
||||
location = 0x28
|
||||
location = 0x2B
|
||||
else:
|
||||
location = 0x12
|
||||
write_list.append((location, [1], self.sram))
|
||||
|
||||
@@ -253,5 +253,17 @@
|
||||
"CubeBot": 529,
|
||||
"Sarda": 525,
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -115,9 +115,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -123,10 +123,8 @@ Vous ne devez faire ces étapes qu'une fois. À noter que RetroArch 1.9.x ne fon
|
||||
1. Entrez dans le menu principal de RetroArch.
|
||||
2. Allez dans Settings --> User Interface. Activez l'option "Show Advanced Settings".
|
||||
3. Allez dans Settings --> Network. Activez l'option "Network Commands", qui se trouve sous "Request Device 16".
|
||||
Laissez le "Network Command Port" à sa valeur par defaut, qui devrait être 55355.
|
||||
|
||||
|
||||

|
||||
Laissez le "Network Command Port" à sa valeur par defaut, qui devrait être 55355. \
|
||||

|
||||
4. Allez dans le Menu Principal --> Online Updater --> Core Downloader. Trouvez et sélectionnez "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import NamedTuple, Union
|
||||
from typing_extensions import deprecated
|
||||
import logging
|
||||
|
||||
from BaseClasses import Item, Tutorial, ItemClassification
|
||||
@@ -49,7 +50,8 @@ class GenericWorld(World):
|
||||
return Item(name, ItemClassification.filler, -1, self.player)
|
||||
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):
|
||||
item: str
|
||||
location: str
|
||||
|
||||
BIN
worlds/generic/docs/retroarch-network-commands-en.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
worlds/generic/docs/retroarch-network-commands-fr.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -5,6 +5,8 @@
|
||||
* A legal copy of Hollow Knight.
|
||||
* Steam, Gog, and Xbox Game Pass versions of the game 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
|
||||
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* Tener una copia legal de Hollow Knight.
|
||||
* Las versiones de Steam, GOG y Xbox Game Pass 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
|
||||
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
|
||||
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,
|
||||
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.
|
||||
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
|
||||
* 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
|
||||
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)
|
||||
for ss in self.checked_locations:
|
||||
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()
|
||||
|
||||
# Handle Slot Data
|
||||
self.slot_data = args['slot_data']
|
||||
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.close()
|
||||
if key == "remote_location_ids":
|
||||
@@ -161,7 +161,7 @@ class KH1Context(CommonContext):
|
||||
found = True
|
||||
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:
|
||||
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.close()
|
||||
self.item_num += 1
|
||||
@@ -170,7 +170,7 @@ class KH1Context(CommonContext):
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
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()
|
||||
|
||||
if cmd in {"PrintJSON"} and "type" in args:
|
||||
@@ -195,7 +195,7 @@ class KH1Context(CommonContext):
|
||||
filename = "msg"
|
||||
if message != "":
|
||||
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.close()
|
||||
if args["type"] == "ItemCheat":
|
||||
@@ -207,7 +207,7 @@ class KH1Context(CommonContext):
|
||||
filename = "msg"
|
||||
message = "Received " + itemName + "\nfrom server"
|
||||
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.close()
|
||||
|
||||
@@ -218,7 +218,7 @@ class KH1Context(CommonContext):
|
||||
logger.info(f"DeathLink: {text}")
|
||||
else:
|
||||
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.close()
|
||||
|
||||
|
||||
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 tradeSequence as _
|
||||
from . import hints
|
||||
from . import utils
|
||||
|
||||
from .patches import bank34
|
||||
from .roomEditor import RoomEditor, Object
|
||||
@@ -231,10 +232,10 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
|
||||
rom.patch(0, 0x0003, "00", "01")
|
||||
|
||||
# Patch the sword check on the shopkeeper turning around.
|
||||
#if ladxr_settings["steal"] == 'never':
|
||||
# rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
|
||||
#elif ladxr_settings["steal"] == 'always':
|
||||
# rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
|
||||
if options["stealing"] == Options.Stealing.option_disabled:
|
||||
rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
|
||||
rom.texts[0x2E] = utils.formatText("Hey! Welcome! Did you know that I have eyes on the back of my head?")
|
||||
rom.texts[0x2F] = utils.formatText("Nothing escapes my gaze! Your thieving ways shall never prosper!")
|
||||
|
||||
#if ladxr_settings["hpmode"] == 'inverted':
|
||||
# patches.health.setStartHealth(rom, 9)
|
||||
@@ -325,7 +326,7 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
|
||||
0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD,
|
||||
|
||||
# Prices
|
||||
0x02C, 0x02D, 0x030, 0x031, 0x032, 0x033, # Shop items
|
||||
0x02C, 0x02D, 0x02E, 0x02F, 0x030, 0x031, 0x032, 0x033, # Shop items
|
||||
0x03B, # Trendy Game
|
||||
0x045, # Fisherman
|
||||
0x018, 0x019, # Crazy Tracy
|
||||
|
||||
@@ -43,8 +43,12 @@ class World:
|
||||
self._addEntrance("start_house", mabe_village, start_house, None)
|
||||
|
||||
shop = Location("Shop")
|
||||
Location().add(ShopItem(0)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 500)), SWORD))
|
||||
Location().add(ShopItem(1)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 1480)), SWORD))
|
||||
if options.steal == "inlogic":
|
||||
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)
|
||||
|
||||
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.
|
||||
[One hit KO] You die on a single hit, always."""),
|
||||
Setting('steal', 'Gameplay', 't', 'Stealing from the shop',
|
||||
options=[('always', 'a', 'Always'), ('never', 'n', 'Never'), ('default', '', 'Normal')], default='default',
|
||||
description="""Effects when you can steal from the shop. Stealing is bad and never in logic.
|
||||
options=[('inlogic', 'a', 'In logic'), ('disabled', 'n', 'Disabled'), ('outoflogic', '', 'Out of logic')], default='outoflogic',
|
||||
description="""Effects when you can steal from the shop and if it is in logic.
|
||||
[Normal] requires the sword before you can steal.
|
||||
[Always] you can always 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"):
|
||||
req("overworld", "normal", "Bingo goal does not work with dungeondive")
|
||||
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("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle")
|
||||
if self.overworld == "dungeondive":
|
||||
|
||||
@@ -325,6 +325,18 @@ class HardMode(Choice, LADXROption):
|
||||
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):
|
||||
"""
|
||||
**Open Mabe:** Replaces rock on the east side of Mabe Village with bushes,
|
||||
@@ -656,6 +668,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
|
||||
nag_messages: NagMessages
|
||||
ap_title_screen: APTitleScreen
|
||||
boots_controls: BootsControls
|
||||
stealing: Stealing
|
||||
quickswap: Quickswap
|
||||
hard_mode: HardMode
|
||||
low_hp_beep: LowHpBeep
|
||||
|
||||
@@ -97,7 +97,7 @@ def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch):
|
||||
"nag_messages",
|
||||
"ap_title_screen",
|
||||
"boots_controls",
|
||||
# "stealing",
|
||||
"stealing",
|
||||
"quickswap",
|
||||
"hard_mode",
|
||||
"low_hp_beep",
|
||||
|
||||
@@ -73,9 +73,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - Gameboy / Color (SameBoy)".
|
||||
|
||||
#### BizHawk 2.8 or newer (older versions untested)
|
||||
|
||||
@@ -48,7 +48,7 @@ A window will open with a few settings to enter:
|
||||
- **Slot name**: Put the player name you specified in your YAML config file in this field.
|
||||
- **Password**: If the server has a password, put it there.
|
||||
|
||||

|
||||

|
||||
|
||||
Once all those fields were filled appropriately, click on the `Connect to Archipelago` button below to try connecting to
|
||||
the Archipelago server.
|
||||
@@ -67,7 +67,7 @@ You should see a window with settings to fill:
|
||||
- **Output ROM directory**: This is where the randomized ROMs will be put. No need to change this unless you want them
|
||||
to be created in a very specific folder.
|
||||
|
||||

|
||||

|
||||
|
||||
There also a few cosmetic options you can fill before clicking the `Build ROM` button which should create your
|
||||
randomized seed if everything went right.
|
||||
@@ -83,7 +83,7 @@ the items you have received from other players.
|
||||
|
||||
You should see the following window:
|
||||
|
||||

|
||||

|
||||
|
||||
As written, you have to open the newly generated ROM inside either Retroarch or Bizhawk using the Genesis Plus GX core.
|
||||
Be careful to select that core, because any other core (e.g. BlastEm) won't work.
|
||||
@@ -116,6 +116,6 @@ The client is packaged with both an **automatic item tracker** and an **automati
|
||||
If you don't know all checks in the game, don't be afraid: you can click the `Where is it?` button that will show
|
||||
you a screenshot of where the location actually is.
|
||||
|
||||

|
||||

|
||||
|
||||
Have fun!
|
||||
Have fun!
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 986 B |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -106,9 +106,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -106,26 +106,38 @@ def tree_zone_4_midway_bell(state, player):
|
||||
|
||||
def tree_zone_4_coins(state, player, coins):
|
||||
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):
|
||||
reachable_coins += 14
|
||||
reachable_coins_from_start += entryway
|
||||
if has_pipe_right(state, player):
|
||||
reachable_coins += 4
|
||||
reachable_coins_from_start += hall
|
||||
if has_pipe_down(state, player):
|
||||
reachable_coins += 10
|
||||
if not auto_scroll:
|
||||
reachable_coins += 46
|
||||
elif state.has("Tree Zone 4 Midway Bell", player):
|
||||
if not auto_scroll:
|
||||
if has_pipe_left(state, player):
|
||||
reachable_coins += 18
|
||||
if has_pipe_down(state, player):
|
||||
reachable_coins += 10
|
||||
if auto_scroll:
|
||||
reachable_coins_from_start += downstairs_with_auto_scroll
|
||||
else:
|
||||
reachable_coins_from_start += final_room + first_trip_downstairs + second_trip_downstairs
|
||||
if state.has("Tree Zone 4 Midway Bell", player):
|
||||
if has_pipe_down(state, player) and (auto_scroll or not has_pipe_left(state, player)):
|
||||
reachable_coins_from_bell += final_room
|
||||
elif has_pipe_left(state, player) and not auto_scroll:
|
||||
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):
|
||||
reachable_coins += 46
|
||||
elif has_pipe_down(state, player):
|
||||
reachable_coins += 10
|
||||
return coins <= reachable_coins
|
||||
reachable_coins_from_bell += second_trip_downstairs + final_room
|
||||
else:
|
||||
reachable_coins_from_bell += entryway + hall
|
||||
return coins <= max(reachable_coins_from_start, reachable_coins_from_bell)
|
||||
|
||||
|
||||
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):
|
||||
if has_pipe_right(state, player):
|
||||
if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Mario Zone 1 Midway Bell"], player):
|
||||
return True
|
||||
if is_auto_scroll(state, player, "Mario Zone 1"):
|
||||
return True
|
||||
return False
|
||||
return has_pipe_right(state, player) and (not is_auto_scroll(state, player, "Mario Zone 1")
|
||||
or state.has_any(["Mushroom", "Fire Flower", "Carrot",
|
||||
"Mario Zone 1 Midway Bell"], player))
|
||||
|
||||
|
||||
def mario_zone_1_midway_bell(state, player):
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
from . import MM2World
|
||||
|
||||
MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497"
|
||||
PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4"
|
||||
PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61"
|
||||
MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632"
|
||||
MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3"
|
||||
|
||||
@@ -404,7 +404,7 @@ def get_base_rom_path(file_name: str = "") -> str:
|
||||
return file_name
|
||||
|
||||
|
||||
PRG_OFFSET = 0x8ED70
|
||||
PRG_OFFSET = 0x8F170
|
||||
PRG_SIZE = 0x40000
|
||||
|
||||
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -84,7 +84,7 @@ Wenn du einer Multiworld beitrittst, wirst du gefordert eine YAML-Datei bei dem
|
||||
erhälst du (in der Regel) einen Link vom Host der Multiworld. Dieser führt dich zu einem Raum, in dem alle
|
||||
teilnehmenden Spieler (bzw. Welten) aufgelistet sind. Du solltest dich dann auf **deine** Welt konzentrieren
|
||||
und klicke dann auf `Download APZ5 File...`.
|
||||

|
||||

|
||||
|
||||
Führe die `.apz5`-Datei mit einem Doppelklick aus, um deinen Ocarina Of Time-Client zu starten, sowie das patchen
|
||||
deiner ROM. Ist dieser Prozess fertig (kann etwas dauern), startet sich der Client und der Emulator automatisch
|
||||
|
||||
@@ -1237,7 +1237,7 @@ saffron_gym_warps = [
|
||||
|
||||
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",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -34,6 +34,7 @@ code contributors also reported bugs and participated in beta testing.
|
||||
* 7thAce (@7thAce) - Pulsar
|
||||
* Panicmoon (@panicmoon.bsky.social) - Skylord
|
||||
* JayborinoPlays (@Jayborino) - Oppressor
|
||||
* MindHawk (@MindHawk) - Caladrius
|
||||
|
||||
## Maintenance of 2024 release
|
||||
* Ziktofel (@Ziktofel)
|
||||
|
||||
@@ -209,7 +209,7 @@ bread_and_butter_settings = {
|
||||
OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default,
|
||||
OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal,
|
||||
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[EnabledCampaigns]: EnabledCampaigns.valid_keys,
|
||||
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
||||
@@ -331,12 +331,13 @@ evil_logic_settings = {
|
||||
OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal,
|
||||
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
|
||||
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[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
||||
OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced,
|
||||
OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines,
|
||||
OPTION_NAME[MaximumCampaignSize]: 35,
|
||||
OPTION_NAME[TwoStartPositions]: TwoStartPositions.option_true,
|
||||
OPTION_NAME[StarterUnit]: StarterUnit.option_off,
|
||||
OPTION_NAME[EnableMorphling]: EnableMorphling.option_true,
|
||||
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,
|
||||
includeuseful: bool) -> bool:
|
||||
includeuseful: bool, floating: bool) -> bool:
|
||||
|
||||
# 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
|
||||
@@ -109,7 +109,7 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: list
|
||||
return False
|
||||
|
||||
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)
|
||||
else:
|
||||
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
|
||||
regions[REGIONS.main].connect(
|
||||
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_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_3], "Using third level building",
|
||||
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_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_5], "Using fifth level building",
|
||||
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.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_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_3], "Using third upgrade building",
|
||||
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_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_5], "Using fifth upgrade building",
|
||||
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
|
||||
regions[REGIONS.main].connect(
|
||||
|
||||
@@ -124,9 +124,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -100,9 +100,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -120,9 +120,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@ You only have to do these steps once.
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
|
||||
Performance)".
|
||||
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -423,7 +423,7 @@ booster_contents: Dict[str, List[str]] = {
|
||||
"Kaiser Glider",
|
||||
"Horus the Black Flame Dragon LV6",
|
||||
"Luster Dragon",
|
||||
"Luster Dragon #2"
|
||||
"Luster Dragon #2",
|
||||
"Spear Dragon",
|
||||
"Armed Dragon LV3",
|
||||
"Armed Dragon LV5",
|
||||
@@ -634,7 +634,7 @@ booster_contents: Dict[str, List[str]] = {
|
||||
"Mystic Swordsman LV6",
|
||||
"Horus the Black Flame Dragon LV6",
|
||||
"Horus the Black Flame Dragon LV4",
|
||||
"Armed Dragon LV3"
|
||||
"Armed Dragon LV3",
|
||||
"Armed Dragon LV5",
|
||||
"Silent Swordsman Lv3",
|
||||
"Silent Swordsman Lv5",
|
||||
@@ -750,7 +750,7 @@ booster_contents: Dict[str, List[str]] = {
|
||||
"Formation Union",
|
||||
"Princess Pikeru",
|
||||
"Skull Zoma",
|
||||
"Metal Reflect Slime"
|
||||
"Metal Reflect Slime",
|
||||
"Level Up!",
|
||||
"Howling Insect",
|
||||
"Tribute Doll",
|
||||
|
||||
@@ -668,7 +668,7 @@ def only_dragon(state, player):
|
||||
], player) and (state.count_from_list_unique([
|
||||
"Luster Dragon",
|
||||
"Spear Dragon",
|
||||
"Cave Dragon"
|
||||
"Cave Dragon",
|
||||
"Armed Dragon LV3",
|
||||
"Masked Dragon",
|
||||
"Twin-Headed Behemoth",
|
||||
|
||||
@@ -20,9 +20,8 @@ RetroArch 1.9.x will not work, as it is older than 1.10.3.
|
||||
- "Sega - MS/GG/MD/CD (Genesis Plus GX)
|
||||
3. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
4. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
Network Command Port at 55355. \
|
||||

|
||||
|
||||
### Linux Setup
|
||||
|
||||
|
||||