Compare commits

..

5 Commits

Author SHA1 Message Date
NewSoupVi
e7632670a6 respect single_player_placement 2025-10-14 20:16:35 +02:00
NewSoupVi
65cf8080b1 rewrite more 2025-10-14 11:47:58 +02:00
NewSoupVi
45eef7d097 Merge branch 'main' into NewSoupVi-patch-26 2025-10-14 10:21:21 +02:00
NewSoupVi
26c1e9b8c3 Remove the overindentation 2024-11-22 16:23:40 +01:00
NewSoupVi
6bca1cbdac Fix Fill choking on itself in minimal + full games 2024-11-22 16:09:24 +01:00
52 changed files with 215 additions and 685 deletions

View File

@@ -9,14 +9,12 @@ on:
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build.yml' - '.github/workflows/build.yml'
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
workflow_dispatch: workflow_dispatch:
env: env:

39
Fill.py
View File

@@ -210,12 +210,43 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
_log_fill_progress(name, placed, total) _log_fill_progress(name, placed, total)
if cleanup_required: if cleanup_required:
relevant_locations = multiworld.get_filled_locations(item.player if single_player_placement else None)
# validate all placements and remove invalid ones # validate all placements and remove invalid ones
state = sweep_from_pool( cleanup_state = sweep_from_pool(base_state, [], relevant_locations)
base_state, [], multiworld.get_filled_locations(item.player)
if single_player_placement else None) # 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)
for placement in placements: for placement in placements:
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state): # 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):
placement.item.location = None placement.item.location = None
unplaced_items.append(placement.item) unplaced_items.append(placement.item)
placement.item = None placement.item = None

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse(argv: list[str] | None = None): def mystery_argparse():
from settings import get_settings from settings import get_settings
settings = get_settings() settings = get_settings()
defaults = settings.generator defaults = settings.generator
@@ -57,7 +57,7 @@ def mystery_argparse(argv: list[str] | None = None):
parser.add_argument("--spoiler_only", action="store_true", parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. " help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.") "Intended for debugging and testing purposes.")
args = parser.parse_args(argv) args = parser.parse_args()
if args.skip_output and args.spoiler_only: if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only") parser.error("Cannot mix --skip_output and --spoiler_only")

View File

@@ -1200,17 +1200,16 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
found = location_id in ctx.location_checks[team, finding_player] found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hint_status = status # Assign again because we're in a for loop
if found: if found:
hint_status = HintStatus.HINT_FOUND status = HintStatus.HINT_FOUND
elif hint_status is None: elif status is None:
if item_flags & ItemClassification.trap: if item_flags & ItemClassification.trap:
hint_status = HintStatus.HINT_AVOID status = HintStatus.HINT_AVOID
else: else:
hint_status = HintStatus.HINT_PRIORITY status = HintStatus.HINT_PRIORITY
hints.append( hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status) Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
) )
return hints return hints

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import concurrent.futures
import json import json
import typing import typing
import builtins import builtins
@@ -478,7 +477,7 @@ class RestrictedUnpickler(pickle.Unpickler):
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoItem, self.options_module.PlandoText)): self.options_module.PlandoText)):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -1139,40 +1138,3 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
if isinstance(obj, str): if isinstance(obj, str):
return False return False
return isinstance(obj, typing.Iterable) return isinstance(obj, typing.Iterable)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
NOTE: use this with caution because killed threads will not properly clean up.
"""
def _adjust_thread_count(self):
# see upstream ThreadPoolExecutor for details
import threading
import weakref
from concurrent.futures.thread import _worker
if self._idle_semaphore.acquire(timeout=0):
return
def weakref_cb(_, q=self._work_queue):
q.put(None)
num_threads = len(self._threads)
if num_threads < self._max_workers:
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
t = threading.Thread(
name=thread_name,
target=_worker,
args=(
weakref.ref(self, weakref_cb),
self._work_queue,
self._initializer,
self._initargs,
),
daemon=True,
)
t.start()
self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown

View File

@@ -1,7 +1,6 @@
import base64 import base64
import os import os
import socket import socket
import typing
import uuid import uuid
from flask import Flask from flask import Flask
@@ -62,21 +61,20 @@ cache = Cache()
Compress(app) Compress(app)
def to_python(value: str) -> uuid.UUID: def to_python(value):
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value: uuid.UUID) -> str: def to_url(value):
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
def to_python(self, value: str) -> uuid.UUID: def to_python(self, value):
return to_python(value) return to_python(value)
def to_url(self, value: typing.Any) -> str: def to_url(self, value):
assert isinstance(value, uuid.UUID)
return to_url(value) return to_url(value)
@@ -86,7 +84,7 @@ app.jinja_env.filters["suuid"] = to_url
app.jinja_env.filters["title_sorted"] = title_sorted app.jinja_env.filters["title_sorted"] = title_sorted
def register() -> None: def register():
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
import importlib import importlib

View File

@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
_stop_event = Event() _stop_event = Event()
def stop() -> None: def stop():
"""Stops previously launched threads""" """Stops previously launched threads"""
global _stop_event global _stop_event
stop_event = _stop_event stop_event = _stop_event
@@ -36,39 +36,25 @@ def handle_generation_failure(result: BaseException):
logging.exception(e) logging.exception(e)
def _mp_gen_game( def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
gen_options: dict,
meta: dict[str, Any] | None = None,
owner=None,
sid=None,
timeout: int|None = None,
) -> PrimaryKey | None:
from setproctitle import setproctitle from setproctitle import setproctitle
setproctitle(f"Generator ({sid})") setproctitle(f"Generator ({sid})")
try: res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout) setproctitle(f"Generator (idle)")
finally: return res
setproctitle(f"Generator (idle)")
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None: def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
try: try:
meta = json.loads(generation.meta) meta = json.loads(generation.meta)
options = restricted_loads(generation.options) options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players") logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async( pool.apply_async(_mp_gen_game, (options,),
_mp_gen_game, {"meta": meta,
(options,), "sid": generation.id,
{ "owner": generation.owner},
"meta": meta, handle_generation_success, handle_generation_failure)
"sid": generation.id,
"owner": generation.owner,
"timeout": timeout,
},
handle_generation_success,
handle_generation_failure,
)
except Exception as e: except Exception as e:
generation.state = STATE_ERROR generation.state = STATE_ERROR
commit() commit()
@@ -149,7 +135,6 @@ def autogen(config: dict):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
initargs=(config,), maxtasksperchild=10) as generator_pool: initargs=(config,), maxtasksperchild=10) as generator_pool:
job_time = config["JOB_TIME"]
with db_session: with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -160,7 +145,7 @@ def autogen(config: dict):
if sid: if sid:
generation.delete() generation.delete()
else: else:
launch_generator(generator_pool, generation, timeout=job_time) launch_generator(generator_pool, generation)
commit() commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete() select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
@@ -172,7 +157,7 @@ def autogen(config: dict):
generation for generation in Generation generation for generation in Generation
if generation.state == STATE_QUEUED).for_update() if generation.state == STATE_QUEUED).for_update()
for generation in to_start: for generation in to_start:
launch_generator(generator_pool, generation, timeout=job_time) launch_generator(generator_pool, generation)
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.") logging.info("Autogen reports as already running, not starting another.")

View File

@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name, mystery_argparse from Generate import PlandoOptions, handle_name, mystery_argparse
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor from Utils import __version__, restricted_dumps
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions from settings import ServerOptions, GeneratorOptions
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
@@ -107,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
else: else:
try: try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"]) meta=meta, owner=session["_id"].int)
except BaseException as e: except BaseException as e:
from .autolauncher import handle_generation_failure from .autolauncher import handle_generation_failure
handle_generation_failure(e) handle_generation_failure(e)
@@ -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)) 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, timeout: int|None = None): def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
if meta is None: if meta is None:
meta = {} 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)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse([]) # Just to set up the Namespace with defaults args = mystery_argparse()
args.multi = playercount args.multi = playercount
args.seed = seed args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
@@ -172,12 +172,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
ERmain(args, seed, baked_server_options=meta["server_options"]) ERmain(args, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task) thread = thread_pool.submit(task)
try: try:
return thread.result(timeout) return thread.result(app.config["JOB_TIME"])
except concurrent.futures.TimeoutError as e: except concurrent.futures.TimeoutError as e:
if sid: if sid:
with db_session: with db_session:
@@ -190,9 +189,6 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
format_exception(e)) format_exception(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
except (KeyboardInterrupt, SystemExit):
# don't update db, retry next time
raise
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:
@@ -204,11 +200,6 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
raise raise
finally:
# free resources claimed by thread pool, if possible
# NOTE: Timeout depends on the process being killed at some point
# since we can't actually cancel a running gen at the moment.
thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/<suuid:seed>') @app.route('/wait/<suuid:seed>')

View File

@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
lines = text.splitlines() lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:])) text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer='html', settings=None, settings_overrides={ return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False, 'raw_enable': False,
'file_insertion_enabled': False, 'file_insertion_enabled': False,
'output_encoding': 'unicode' 'output_encoding': 'unicode'

View File

@@ -4,11 +4,9 @@ pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress>=3.0.2 waitress>=3.0.2
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.17; python_version >= '3.12' Flask-Compress>=1.17
Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency
Flask-Limiter>=3.12 Flask-Limiter>=3.12
bokeh>=3.6.3 bokeh>=3.6.3
markupsafe>=3.0.2 markupsafe>=3.0.2
setproctitle>=1.3.5 setproctitle>=1.3.5
mistune>=3.1.3 mistune>=3.1.3
docutils>=0.22.2

View File

@@ -1,49 +1,49 @@
# APWorld Specification # apworld Specification
Archipelago depends on worlds to provide game-specific details like items, locations and output generation. Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
These are called "APWorlds". Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
See [world api.md](world%20api.md) for details. See [world api.md](world%20api.md) for details.
APWorlds can either be a folder, or they can be packaged as an .apworld file.
## .apworld File Format 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.
The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution **Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
by placing a `*.apworld` file into the worlds folder.
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
## File Format
apworld files are zip archives, all lower case, with the file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
**Warning:** `.apworld` files have to be all lower case,
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
## Metadata ## Metadata
Metadata about the APWorld is defined in an `archipelago.json` file. Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive.
The current format version has at minimum:
If the APWorld is a folder, the only required field is "game":
```json ```json
{ {
"game": "Game Name" "version": 7,
"compatible_version": 7,
"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: There are also the following optional fields:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current * `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. 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. * `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`) (**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 * `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. 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 ### "Build apworlds" Launcher Component
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`, 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 ## Caveats
Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions` Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
`from worlds.AutoWorld import World` `from worlds.AutoWorld import World`

11
kvui.py
View File

@@ -34,17 +34,6 @@ from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch") Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0") Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
# Workaround for an issue where importing kivy.core.window before loading sounds
# will hang the whole application on Linux once the first sound is loaded.
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
# No longer necessary when we switch to kivy 3.0.0, which fixes this issue.
from kivy.core.audio import SoundLoader
for classobj in SoundLoader._classes:
# The least invasive way to force a SoundLoader class to load its audio engine seems to be calling
# .extensions(), which e.g. in audio_sdl2.pyx then calls a function called "mix_init()"
classobj.extensions()
from kivymd.uix.divider import MDDivider from kivymd.uix.divider import MDDivider
from kivy.core.window import Window from kivy.core.window import Window
from kivy.core.clipboard import Clipboard from kivy.core.clipboard import Clipboard

View File

@@ -1,16 +0,0 @@
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.
]

View File

@@ -146,16 +146,7 @@ def download_SNI() -> None:
signtool: str | None = None signtool: str | None = None
try: try:
import socket with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
sign_host, sign_port = "192.168.206.4", 12345
# check if the sign_host is on a local network
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((sign_host, sign_port))
if s.getsockname()[0].rsplit(".", 1)[0] != sign_host.rsplit(".", 1)[0]:
raise ConnectionError() # would go through default route
# configure signtool
with urllib.request.urlopen(f"http://{sign_host}:{sign_port}/connector/status") as response:
html = response.read() html = response.read()
if b"status=OK\n" in html: if b"status=OK\n" in html:
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 ' signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
@@ -390,8 +381,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name world_directory = self.libfolder / "worlds" / file_name
if os.path.isfile(world_directory / "archipelago.json"): if os.path.isfile(world_directory / "archipelago.json"):
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file: manifest = json.load(open(world_directory / "archipelago.json"))
manifest = json.load(manifest_file)
assert "game" in manifest, ( assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it" f"World directory {world_directory} has an archipelago.json manifest file, but it"

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from BaseClasses import PlandoOptions from BaseClasses import PlandoOptions
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts from Options import ItemLinks, Choice
from Utils import restricted_dumps from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
for link in item_links.values(): for link in item_links.values():
self.assertEqual(link.value[0], item_link_group[0]) self.assertEqual(link.value[0], item_link_group[0])
def test_pickle_dumps_default(self): def test_pickle_dumps(self):
"""Test that default option values can be pickled into database for WebHost generation""" """Test options can be pickled into database for WebHost generation"""
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden: if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
@@ -81,23 +81,3 @@ class TestOptions(unittest.TestCase):
restricted_dumps(option.from_any(option.default)) restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup: if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default])) restricted_dumps(option.from_text(option.name_lookup[option.default]))
def test_pickle_dumps_plando(self):
"""Test that plando options using containers of a custom type can be pickled"""
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
class TestPlandoConnections(PlandoConnections):
entrances = {"An Entrance"}
exits = {"An Exit"}
plando_connection_value = PlandoConnections(
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
)
plando_values = {
"PlandoConnections": plando_connection_value,
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
}
for option_key, value in plando_values.items():
with self.subTest(option=option_key):
restricted_dumps(value)

View File

@@ -1,102 +0,0 @@
"""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.",
)

View File

@@ -3,7 +3,6 @@
# Run with `python test/hosting` instead, # Run with `python test/hosting` instead,
import logging import logging
import traceback import traceback
from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from time import sleep from time import sleep
from typing import Any from typing import Any
@@ -12,7 +11,7 @@ from test.hosting.client import Client
from test.hosting.generate import generate_local from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autogen, stop_autohost, upload_multidata, generate_remote) stop_autohost, upload_multidata)
from test.hosting.world import copy as copy_world, delete as delete_world from test.hosting.world import copy as copy_world, delete as delete_world
failure = False failure = False
@@ -57,62 +56,35 @@ else:
if __name__ == "__main__": if __name__ == "__main__":
import sys
import warnings import warnings
warnings.simplefilter("ignore", ResourceWarning) warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning) warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", DeprecationWarning)
spacer = '=' * 80 spacer = '=' * 80
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games: list[str] = [] p1_games = []
data_paths: list[Path | None] = [] data_paths = []
rooms: list[str] = [] rooms = []
multidata: Path | None
copy_world("VVVVVV", "Temp World") copy_world("VVVVVV", "Temp World")
try: try:
for n, games in enumerate(multis, 1): for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)} offline") print(f"Generating [{n}] {', '.join(games)}")
multidata = generate_local(games, tempdir) multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
data_paths.append(multidata)
p1_games.append(games[0]) p1_games.append(games[0])
data_paths.append(multidata)
finally: finally:
delete_world("Temp World") delete_world("Temp World")
webapp = get_app(tempdir) webapp = get_app(tempdir)
webhost_client = webapp.test_client() webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1): for n, multidata in enumerate(data_paths, 1):
assert multidata
seed = upload_multidata(webhost_client, multidata) seed = upload_multidata(webhost_client, multidata)
print(f"Uploaded [{n}] {multidata} as {seed}\n")
room = create_room(webhost_client, seed) room = create_room(webhost_client, seed)
print(f"Started [{n}] {seed} as {room}\n") print(f"Uploaded [{n}] {multidata} as {room}\n")
rooms.append(room)
# Generate 1 extra game on WebHost
from WebHostLib.autolauncher import autogen
for n, games in enumerate(multis[:1], len(multis) + 1):
multis.append(games)
try:
print(f"Generating [{n}] {', '.join(games)} online")
autogen(webapp.config)
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
seed = generate_remote(webhost_client, games)
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
finally:
stop_autogen()
data_paths.append(None) # WebHost-only
room = create_room(webhost_client, seed)
print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room) rooms.append(room)
print("Starting autohost") print("Starting autohost")
@@ -124,10 +96,31 @@ if __name__ == "__main__":
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games) involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3): for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
prev_host_adr = host.address prev_host_adr = host.address
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages web_data_packages = client.games_packages
@@ -141,7 +134,6 @@ if __name__ == "__main__":
autohost(webapp.config) # this will spin the room right up again autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below # if saving failed, the next iteration will fail below
sleep(2) # work around issue #5571
# verify server shut down # verify server shut down
try: try:
@@ -164,31 +156,6 @@ if __name__ == "__main__":
"customserver did not load or save correctly during/after " "customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit")) + ("Ctrl+C" if collected_items == 2 else "/exit"))
if not multidata:
continue # games rolled on WebHost can not be tested against MultiServer
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datapackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
# compare customserver to MultiServer # compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages, expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer") "customserver datapackage differs from MultiServer")
@@ -209,12 +176,10 @@ if __name__ == "__main__":
print(f"Restoring multidata for {room}") print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data) set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2, assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver") "Save was destroyed during exception in customserver")
print("Save file is not busted 🥳") print("Save file is not busted 🥳")
sleep(2) # work around issue #5571
finally: finally:
print("Stopping autohost") print("Stopping autohost")

View File

@@ -1,10 +1,6 @@
import io
import json
import re import re
import time
import zipfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Iterable, Optional, cast from typing import TYPE_CHECKING, Optional, cast
from WebHostLib import to_python from WebHostLib import to_python
@@ -14,7 +10,6 @@ if TYPE_CHECKING:
__all__ = [ __all__ = [
"get_app", "get_app",
"generate_remote",
"upload_multidata", "upload_multidata",
"create_room", "create_room",
"start_room", "start_room",
@@ -22,7 +17,6 @@ __all__ = [
"set_room_timeout", "set_room_timeout",
"get_multidata_for_room", "get_multidata_for_room",
"set_multidata_for_room", "set_multidata_for_room",
"stop_autogen",
"stop_autohost", "stop_autohost",
] ]
@@ -39,43 +33,10 @@ def get_app(tempdir: str) -> "Flask":
"TESTING": True, "TESTING": True,
"HOST_ADDRESS": "localhost", "HOST_ADDRESS": "localhost",
"HOSTERS": 1, "HOSTERS": 1,
"GENERATORS": 1,
"JOB_THRESHOLD": 1,
}) })
return get_app() return get_app()
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
data = io.BytesIO()
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
for n, game in enumerate(games, 1):
name = f"{n}.yaml"
zip_file.writestr(name, json.dumps({
"name": f"Player{n}",
"game": game,
game: {},
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
}))
data.seek(0)
response = app_client.post("/generate", content_type="multipart/form-data", data={
"file": (data, "yamls.zip"),
})
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
assert "Location" in response.headers, f"Starting gen failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
for attempt in range(10):
response = app_client.get(location)
if "Location" in response.headers:
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
return location[6:]
time.sleep(1)
raise TimeoutError("WebHost gen did not finish")
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={ response = app_client.post("/uploads", data={
"file": multidata.open("rb"), "file": multidata.open("rb"),
@@ -227,7 +188,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
room.seed.multidata = data room.seed.multidata = data
def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None: def stop_autohost(graceful: bool = True) -> None:
import os import os
import signal import signal
@@ -237,30 +198,13 @@ def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
stop() stop()
proc: multiprocessing.process.BaseProcess proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()): for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
# and ungraceful may not save the game
if proc.pid == os.getpid():
continue
if graceful and proc.pid: if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else: else:
proc.kill() proc.kill()
try: try:
try: proc.join(30)
proc.join(30)
except TimeoutError:
raise
except KeyboardInterrupt:
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
proc.join(30)
except TimeoutError: except TimeoutError:
proc.kill() proc.kill()
proc.join() proc.join()
def stop_autogen(graceful: bool = True) -> None:
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
_stop_webhost_mp("SpawnPoolWorker-", graceful)
def stop_autohost(graceful: bool = True) -> None:
_stop_webhost_mp("MultiHoster", graceful)

View File

@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
def copy(src: str, dst: str) -> None: def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name from Utils import get_file_safe_name
from worlds.AutoWorld import AutoWorldRegister from worlds import AutoWorldRegister
assert dst not in _new_worlds, "World already created" assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape if '"' in dst or "\\" in dst: # easier to reject than to escape

View File

@@ -1,14 +0,0 @@
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)

View File

@@ -271,8 +271,7 @@ if not is_frozen():
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = os.path.join("worlds", file_name) world_directory = os.path.join("worlds", file_name)
if os.path.isfile(os.path.join(world_directory, "archipelago.json")): if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file: manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
manifest = json.load(manifest_file)
assert "game" in manifest, ( assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it" f"World directory {world_directory} has an archipelago.json manifest file, but it"

View File

@@ -122,8 +122,7 @@ for world_source in world_sources:
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path): for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
for file in filenames: for file in filenames:
if file.endswith("archipelago.json"): if file.endswith("archipelago.json"):
with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file: manifest = json.load(open(os.path.join(dirpath, file), "r"))
manifest = json.load(manifest_file)
break break
if manifest: if manifest:
break break

View File

@@ -1,6 +1,4 @@
import asyncio import asyncio
import time
import Utils import Utils
import websockets import websockets
import functools import functools
@@ -210,9 +208,6 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
if not ctx.is_proxy_connected(): if not ctx.is_proxy_connected():
break break
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
msg["data"]["time"] = time.time()
await ctx.send_msgs([msg]) await ctx.send_msgs([msg])
except Exception as e: except Exception as e:

View File

@@ -243,7 +243,7 @@ guaranteed_first_acts = [
"Time Rift - Mafia of Cooks", "Time Rift - Mafia of Cooks",
"Time Rift - Dead Bird Studio", "Time Rift - Dead Bird Studio",
"Time Rift - Sleepy Subcon", "Time Rift - Sleepy Subcon",
"Time Rift - Alpine Skyline", "Time Rift - Alpine Skyline"
"Time Rift - Tour", "Time Rift - Tour",
"Time Rift - Rumbi Factory", "Time Rift - Rumbi Factory",
] ]

View File

@@ -20,7 +20,6 @@ class CivVIBoostData:
Prereq: List[str] Prereq: List[str]
PrereqRequiredCount: int PrereqRequiredCount: int
Classification: str Classification: str
EraRequired: bool = False
class GoodyHutRewardData(TypedDict): class GoodyHutRewardData(TypedDict):

View File

@@ -150,10 +150,7 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
location = CivVILocationData( location = CivVILocationData(
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
) )
# If EraRequired is True, place the boost in its actual era era_locations["ERA_ANCIENT"][boost.Type] = location
# Otherwise, place it in ERA_ANCIENT for early access
target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT"
era_locations[target_era][boost.Type] = location
id_base += 1 id_base += 1
return era_locations return era_locations

View File

@@ -210,8 +210,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_SQUARE_RIGGING", "BOOST_TECH_SQUARE_RIGGING",
"ERA_RENAISSANCE", "ERA_RENAISSANCE",
["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"], ["TECH_GUNPOWDER"],
3, 1,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -252,15 +252,15 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_BALLISTICS", "BOOST_TECH_BALLISTICS",
"ERA_INDUSTRIAL", "ERA_INDUSTRIAL",
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"], ["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"],
3, 2,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_MILITARY_SCIENCE", "BOOST_TECH_MILITARY_SCIENCE",
"ERA_INDUSTRIAL", "ERA_INDUSTRIAL",
["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"], ["TECH_STIRRUPS"],
3, 1,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -301,8 +301,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_REPLACEABLE_PARTS", "BOOST_TECH_REPLACEABLE_PARTS",
"ERA_MODERN", "ERA_MODERN",
["TECH_MILITARY_SCIENCE", "TECH_MINING"], ["TECH_MILITARY_SCIENCE"],
2, 1,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -343,8 +343,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_ADVANCED_FLIGHT", "BOOST_TECH_ADVANCED_FLIGHT",
"ERA_ATOMIC", "ERA_ATOMIC",
["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"], ["TECH_FLIGHT"],
3, 1,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -436,8 +436,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_COMPOSITES", "BOOST_TECH_COMPOSITES",
"ERA_INFORMATION", "ERA_INFORMATION",
["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"], ["TECH_COMBUSTION"],
3, 1,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -470,7 +470,7 @@ boosts: List[CivVIBoostData] = [
"TECH_ELECTRICITY", "TECH_ELECTRICITY",
"TECH_NUCLEAR_FISSION", "TECH_NUCLEAR_FISSION",
], ],
4, 1,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -651,11 +651,10 @@ boosts: List[CivVIBoostData] = [
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_FEUDALISM", "BOOST_CIVIC_FEUDALISM",
"ERA_CLASSICAL", "ERA_MEDIEVAL",
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True,
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_CIVIL_SERVICE", "BOOST_CIVIC_CIVIL_SERVICE",
@@ -663,7 +662,6 @@ boosts: List[CivVIBoostData] = [
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True,
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_MERCENARIES", "BOOST_CIVIC_MERCENARIES",
@@ -792,7 +790,6 @@ boosts: List[CivVIBoostData] = [
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_CONSERVATION", "BOOST_CIVIC_CONSERVATION",
@@ -888,7 +885,6 @@ boosts: List[CivVIBoostData] = [
["TECH_ROCKETRY"], ["TECH_ROCKETRY"],
1, 1,
"DEFAULT", "DEFAULT",
True
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_GLOBALIZATION", "BOOST_CIVIC_GLOBALIZATION",

View File

@@ -105,78 +105,3 @@ class TestBoostsanityExcluded(CivVITestBase):
if "BOOST" in location.name: if "BOOST" in location.name:
found_locations += 1 found_locations += 1
self.assertEqual(found_locations, 0) self.assertEqual(found_locations, 0)
class TestBoostsanityEraRequired(CivVITestBase):
options = {
"boostsanity": "true",
"progression_style": "none",
"shuffle_goody_hut_rewards": "false",
}
def test_era_required_boosts_not_accessible_early(self) -> None:
# BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE"))
# Regular boosts without EraRequired should be accessible
self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING"))
self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION"))
def test_era_required_boosts_accessible_in_correct_era(self) -> None:
# Collect items to reach Classical era
self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing",
"Irrigation", "Sailing", "Animal Husbandry",
"State Workforce", "Foreign Trade"])
# BOOST_CIVIC_FEUDALISM should now be accessible in Classical era
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial)
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect more items to reach Industrial era
self.collect_all_but(["TECH_ROCKETRY"])
# Now BOOST_CIVIC_URBANIZATION should be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
class TestBoostsanityEraRequiredWithProgression(CivVITestBase):
options = {
"boostsanity": "true",
"progression_style": "eras_and_districts",
"shuffle_goody_hut_rewards": "false",
}
def test_era_required_with_progressive_eras(self) -> None:
# Collect all items except Progressive Era
self.collect_all_but(["Progressive Era"])
# Even with all other items, era-required boosts should not be accessible
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect enough Progressive Era items to reach Classical (needs 2)
self.collect(self.get_item_by_name("Progressive Era"))
self.collect(self.get_item_by_name("Progressive Era"))
# BOOST_CIVIC_FEUDALISM should now be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total)
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect 3 more Progressive Era items to reach Industrial
self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"])
# Now BOOST_CIVIC_URBANIZATION should be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))

View File

@@ -2,7 +2,6 @@ import csv
import enum import enum
import math import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import reduce
from random import Random from random import Random
from typing import Dict, List, Set from typing import Dict, List, Set
@@ -62,7 +61,7 @@ def load_item_csv():
item_reader = csv.DictReader(file) item_reader = csv.DictReader(file)
for item in item_reader: for item in item_reader:
id = int(item["id"]) if item["id"] else None id = int(item["id"]) if item["id"] else None
classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")}) classification = ItemClassification[item["classification"]]
groups = {Group[group] for group in item["groups"].split(",") if group} groups = {Group[group] for group in item["groups"].split(",") if group}
items.append(ItemData(id, item["name"], classification, groups)) items.append(ItemData(id, item["name"], classification, groups))
return items return items

View File

@@ -22,7 +22,7 @@ id,name,classification,groups
20,Wall Jump Pack,progression,"DLC,Freemium" 20,Wall Jump Pack,progression,"DLC,Freemium"
21,Health Bar Pack,useful,"DLC,Freemium" 21,Health Bar Pack,useful,"DLC,Freemium"
22,Parallax Pack,filler,"DLC,Freemium" 22,Parallax Pack,filler,"DLC,Freemium"
23,Harmless Plants Pack,"progression,trap","DLC,Freemium" 23,Harmless Plants Pack,progression,"DLC,Freemium"
24,Death of Comedy Pack,progression,"DLC,Freemium" 24,Death of Comedy Pack,progression,"DLC,Freemium"
25,Canadian Dialog Pack,filler,"DLC,Freemium" 25,Canadian Dialog Pack,filler,"DLC,Freemium"
26,DLC NPC Pack,progression,"DLC,Freemium" 26,DLC NPC Pack,progression,"DLC,Freemium"
1 id name classification groups
22 20 Wall Jump Pack progression DLC,Freemium
23 21 Health Bar Pack useful DLC,Freemium
24 22 Parallax Pack filler DLC,Freemium
25 23 Harmless Plants Pack progression,trap progression DLC,Freemium
26 24 Death of Comedy Pack progression DLC,Freemium
27 25 Canadian Dialog Pack filler DLC,Freemium
28 26 DLC NPC Pack progression DLC,Freemium

View File

@@ -16,7 +16,6 @@ logger = logging.getLogger("Client")
rom_name_location = 0x07FFE3 rom_name_location = 0x07FFE3
player_name_location = 0x07BCC0
locations_array_start = 0x200 locations_array_start = 0x200
locations_array_length = 0x100 locations_array_length = 0x100
items_obtained = 0x03 items_obtained = 0x03
@@ -112,12 +111,6 @@ class FF1Client(BizHawkClient):
return True return True
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
auth_raw = (await bizhawk.read(
ctx.bizhawk_ctx,
[(player_name_location, 0x40, self.rom)]))[0]
ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip()
async def game_watcher(self, ctx: "BizHawkClientContext") -> None: async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.server is None: if ctx.server is None:
return return
@@ -211,7 +204,7 @@ class FF1Client(BizHawkClient):
write_list.append((location, [0], self.sram)) write_list.append((location, [0], self.sram))
elif current_item_name in no_overworld_items: elif current_item_name in no_overworld_items:
if current_item_name == "Sigil": if current_item_name == "Sigil":
location = 0x2B location = 0x28
else: else:
location = 0x12 location = 0x12
write_list.append((location, [1], self.sram)) write_list.append((location, [1], self.sram))

View File

@@ -253,17 +253,5 @@
"CubeBot": 529, "CubeBot": 529,
"Sarda": 525, "Sarda": 525,
"Fairy": 531, "Fairy": 531,
"Lefein": 527, "Lefein": 527
"DeepDungeon32B_Chest144": 401,
"DeepDungeon30B_Chest145": 402,
"DeepDungeon29B_Chest146": 403,
"DeepDungeon29B_Chest147": 404,
"DeepDungeon40B_Chest186": 443,
"DeepDungeon38B_Chest188": 445,
"DeepDungeon36B_Chest189": 446,
"DeepDungeon33B_Chest190": 447,
"DeepDungeon40B_Chest191": 448,
"DeepDungeon41B_Chest192": 449,
"DeepDungeon34B_Chest193": 450,
"DeepDungeon39B_Chest194": 451
} }

View File

@@ -1,5 +1,4 @@
from typing import NamedTuple, Union from typing import NamedTuple, Union
from typing_extensions import deprecated
import logging import logging
from BaseClasses import Item, Tutorial, ItemClassification from BaseClasses import Item, Tutorial, ItemClassification
@@ -50,8 +49,7 @@ class GenericWorld(World):
return Item(name, ItemClassification.filler, -1, self.player) return Item(name, ItemClassification.filler, -1, self.player)
raise InvalidItemError(name) raise InvalidItemError(name)
@deprecated("worlds.generic.PlandoItem is deprecated and will be removed in the next version. "
"Use Options.PlandoItem(s) instead.")
class PlandoItem(NamedTuple): class PlandoItem(NamedTuple):
item: str item: str
location: str location: str

View File

@@ -6,8 +6,6 @@
* Steam, Gog, and Xbox Game Pass versions of the game are supported. * Steam, Gog, and Xbox Game Pass versions of the game are supported.
* Windows, Mac, and Linux (including Steam Deck) are supported. * Windows, Mac, and Linux (including Steam Deck) are supported.
**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods.
## Installing the Archipelago Mod using Lumafly ## Installing the Archipelago Mod using Lumafly
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory. 1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
2. Install the Archipelago mods by doing either of the following: 2. Install the Archipelago mods by doing either of the following:

View File

@@ -6,10 +6,6 @@
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles * Las versiones de Steam, GOG y Xbox Game Pass son compatibles
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles * Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods)
usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y
similares, en vez de sitios web como Nexus Mods.
## Instalación del mod de Archipelago con Lumafly ## Instalación del mod de Archipelago con Lumafly
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight 1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
2. Instala el mod de Archipiélago haciendo click en cualquiera de los siguientes: 2. Instala el mod de Archipiélago haciendo click en cualquiera de los siguientes:

View File

@@ -6,10 +6,6 @@
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas. * Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados. * Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods)
usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes,
ao invés de sites como o Nexus Mods.
## Instalando o mod Archipelago Mod usando Lumafly ## Instalando o mod Archipelago Mod usando Lumafly
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight. 1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
2. Clique em "Install (instalar)" perto da opção "Archipelago" mod. 2. Clique em "Install (instalar)" perto da opção "Archipelago" mod.

View File

@@ -134,13 +134,13 @@ class KH1Context(CommonContext):
os.makedirs(self.game_communication_path) os.makedirs(self.game_communication_path)
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.close() f.close()
# Handle Slot Data # Handle Slot Data
self.slot_data = args['slot_data'] self.slot_data = args['slot_data']
for key in list(args['slot_data'].keys()): for key in list(args['slot_data'].keys()):
with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w', encoding='utf-8') as f: with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w') as f:
f.write(str(args['slot_data'][key])) f.write(str(args['slot_data'][key]))
f.close() f.close()
if key == "remote_location_ids": if key == "remote_location_ids":
@@ -161,7 +161,7 @@ class KH1Context(CommonContext):
found = True found = True
if not found: if not found:
if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot: if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot:
with open(os.path.join(self.game_communication_path, item_filename), 'w', encoding='utf-8') as f: with open(os.path.join(self.game_communication_path, item_filename), 'w') as f:
f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player)) f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player))
f.close() f.close()
self.item_num += 1 self.item_num += 1
@@ -170,7 +170,7 @@ class KH1Context(CommonContext):
if "checked_locations" in args: if "checked_locations" in args:
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.close() f.close()
if cmd in {"PrintJSON"} and "type" in args: if cmd in {"PrintJSON"} and "type" in args:
@@ -195,7 +195,7 @@ class KH1Context(CommonContext):
filename = "msg" filename = "msg"
if message != "": if message != "":
if not os.path.exists(self.game_communication_path + "/" + filename): if not os.path.exists(self.game_communication_path + "/" + filename):
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(message) f.write(message)
f.close() f.close()
if args["type"] == "ItemCheat": if args["type"] == "ItemCheat":
@@ -207,7 +207,7 @@ class KH1Context(CommonContext):
filename = "msg" filename = "msg"
message = "Received " + itemName + "\nfrom server" message = "Received " + itemName + "\nfrom server"
if not os.path.exists(self.game_communication_path + "/" + filename): if not os.path.exists(self.game_communication_path + "/" + filename):
with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(message) f.write(message)
f.close() f.close()
@@ -218,7 +218,7 @@ class KH1Context(CommonContext):
logger.info(f"DeathLink: {text}") logger.info(f"DeathLink: {text}")
else: else:
logger.info(f"DeathLink: Received from {data['source']}") logger.info(f"DeathLink: Received from {data['source']}")
with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w', encoding='utf-8') as f: with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w') as f:
f.write(str(int(data["time"]))) f.write(str(int(data["time"])))
f.close() f.close()

View File

@@ -1,6 +0,0 @@
{
"game": "Kingdom Hearts 2",
"authors": [ "JaredWeakStrike" ],
"minimum_ap_version": "0.6.3",
"world_version": "2.0.0"
}

View File

@@ -57,7 +57,6 @@ from .patches import bingo as _
from .patches import multiworld as _ from .patches import multiworld as _
from .patches import tradeSequence as _ from .patches import tradeSequence as _
from . import hints from . import hints
from . import utils
from .patches import bank34 from .patches import bank34
from .roomEditor import RoomEditor, Object from .roomEditor import RoomEditor, Object
@@ -232,10 +231,10 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
rom.patch(0, 0x0003, "00", "01") rom.patch(0, 0x0003, "00", "01")
# Patch the sword check on the shopkeeper turning around. # Patch the sword check on the shopkeeper turning around.
if options["stealing"] == Options.Stealing.option_disabled: #if ladxr_settings["steal"] == 'never':
rom.patch(4, 0x36F9, "FA4EDB", "3E0000") # 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?") #elif ladxr_settings["steal"] == 'always':
rom.texts[0x2F] = utils.formatText("Nothing escapes my gaze! Your thieving ways shall never prosper!") # rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
#if ladxr_settings["hpmode"] == 'inverted': #if ladxr_settings["hpmode"] == 'inverted':
# patches.health.setStartHealth(rom, 9) # patches.health.setStartHealth(rom, 9)
@@ -326,7 +325,7 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD, 0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD,
# Prices # Prices
0x02C, 0x02D, 0x02E, 0x02F, 0x030, 0x031, 0x032, 0x033, # Shop items 0x02C, 0x02D, 0x030, 0x031, 0x032, 0x033, # Shop items
0x03B, # Trendy Game 0x03B, # Trendy Game
0x045, # Fisherman 0x045, # Fisherman
0x018, 0x019, # Crazy Tracy 0x018, 0x019, # Crazy Tracy

View File

@@ -43,12 +43,8 @@ class World:
self._addEntrance("start_house", mabe_village, start_house, None) self._addEntrance("start_house", mabe_village, start_house, None)
shop = Location("Shop") shop = Location("Shop")
if options.steal == "inlogic": Location().add(ShopItem(0)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 500)), SWORD))
Location().add(ShopItem(0)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 500)))) Location().add(ShopItem(1)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 1480)), SWORD))
Location().add(ShopItem(1)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 1480))))
else:
Location().add(ShopItem(0)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 500)))
Location().add(ShopItem(1)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 1480)))
self._addEntrance("shop", mabe_village, shop, None) self._addEntrance("shop", mabe_village, shop, None)
dream_hut = Location("Dream Hut") dream_hut = Location("Dream Hut")

View File

@@ -162,8 +162,8 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
[Hero] Switch version hero mode, double damage, no heart/fairy drops. [Hero] Switch version hero mode, double damage, no heart/fairy drops.
[One hit KO] You die on a single hit, always."""), [One hit KO] You die on a single hit, always."""),
Setting('steal', 'Gameplay', 't', 'Stealing from the shop', Setting('steal', 'Gameplay', 't', 'Stealing from the shop',
options=[('inlogic', 'a', 'In logic'), ('disabled', 'n', 'Disabled'), ('outoflogic', '', 'Out of logic')], default='outoflogic', options=[('always', 'a', 'Always'), ('never', 'n', 'Never'), ('default', '', 'Normal')], default='default',
description="""Effects when you can steal from the shop and if it is in logic. description="""Effects when you can steal from the shop. Stealing is bad and never in logic.
[Normal] requires the sword before you can steal. [Normal] requires the sword before you can steal.
[Always] you can always steal from the shop [Always] you can always steal from the shop
[Never] you can never steal from the shop."""), [Never] you can never steal from the shop."""),
@@ -286,7 +286,7 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
if self.goal in ("bingo", "bingo-full"): if self.goal in ("bingo", "bingo-full"):
req("overworld", "normal", "Bingo goal does not work with dungeondive") req("overworld", "normal", "Bingo goal does not work with dungeondive")
req("accessibility", "all", "Bingo goal needs 'all' accessibility") req("accessibility", "all", "Bingo goal needs 'all' accessibility")
dis("steal", "disabled", "default", "With bingo goal, stealing should be allowed") dis("steal", "never", "default", "With bingo goal, stealing should be allowed")
dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle") dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle")
dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle") dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle")
if self.overworld == "dungeondive": if self.overworld == "dungeondive":

View File

@@ -325,18 +325,6 @@ class HardMode(Choice, LADXROption):
default = option_none default = option_none
class Stealing(Choice, LADXROption):
"""
Puts stealing from the shop in logic if the player has a sword.
"""
display_name = "Stealing"
ladxr_name = "steal"
option_in_logic = 1
option_out_of_logic = 2
option_disabled = 3
default = option_out_of_logic
class Overworld(Choice, LADXROption): class Overworld(Choice, LADXROption):
""" """
**Open Mabe:** Replaces rock on the east side of Mabe Village with bushes, **Open Mabe:** Replaces rock on the east side of Mabe Village with bushes,
@@ -668,7 +656,6 @@ class LinksAwakeningOptions(PerGameCommonOptions):
nag_messages: NagMessages nag_messages: NagMessages
ap_title_screen: APTitleScreen ap_title_screen: APTitleScreen
boots_controls: BootsControls boots_controls: BootsControls
stealing: Stealing
quickswap: Quickswap quickswap: Quickswap
hard_mode: HardMode hard_mode: HardMode
low_hp_beep: LowHpBeep low_hp_beep: LowHpBeep

View File

@@ -97,7 +97,7 @@ def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch):
"nag_messages", "nag_messages",
"ap_title_screen", "ap_title_screen",
"boots_controls", "boots_controls",
"stealing", # "stealing",
"quickswap", "quickswap",
"hard_mode", "hard_mode",
"low_hp_beep", "low_hp_beep",

View File

@@ -106,38 +106,26 @@ def tree_zone_4_midway_bell(state, player):
def tree_zone_4_coins(state, player, coins): def tree_zone_4_coins(state, player, coins):
auto_scroll = is_auto_scroll(state, player, "Tree Zone 4") auto_scroll = is_auto_scroll(state, player, "Tree Zone 4")
entryway = 14 reachable_coins = 0
hall = 4
first_trip_downstairs = 31
second_trip_downstairs = 15
downstairs_with_auto_scroll = 12
final_room = 10
reachable_coins_from_start = 0
reachable_coins_from_bell = 0
if has_pipe_up(state, player): if has_pipe_up(state, player):
reachable_coins_from_start += entryway reachable_coins += 14
if has_pipe_right(state, player): if has_pipe_right(state, player):
reachable_coins_from_start += hall reachable_coins += 4
if has_pipe_down(state, player): if has_pipe_down(state, player):
if auto_scroll: reachable_coins += 10
reachable_coins_from_start += downstairs_with_auto_scroll if not auto_scroll:
else: reachable_coins += 46
reachable_coins_from_start += final_room + first_trip_downstairs + second_trip_downstairs elif state.has("Tree Zone 4 Midway Bell", player):
if state.has("Tree Zone 4 Midway Bell", player): if not auto_scroll:
if has_pipe_down(state, player) and (auto_scroll or not has_pipe_left(state, player)): if has_pipe_left(state, player):
reachable_coins_from_bell += final_room reachable_coins += 18
elif has_pipe_left(state, player) and not auto_scroll: if has_pipe_down(state, player):
if has_pipe_down(state, player): reachable_coins += 10
reachable_coins_from_bell += first_trip_downstairs
if has_pipe_right(state, player):
reachable_coins_from_bell += entryway + hall
if has_pipe_up(state, player): if has_pipe_up(state, player):
reachable_coins_from_bell += second_trip_downstairs + final_room reachable_coins += 46
else: elif has_pipe_down(state, player):
reachable_coins_from_bell += entryway + hall reachable_coins += 10
return coins <= max(reachable_coins_from_start, reachable_coins_from_bell) return coins <= reachable_coins
def tree_zone_5_boss(state, player): def tree_zone_5_boss(state, player):
@@ -251,9 +239,12 @@ def pumpkin_zone_4_coins(state, player, coins):
def mario_zone_1_normal_exit(state, player): def mario_zone_1_normal_exit(state, player):
return has_pipe_right(state, player) and (not is_auto_scroll(state, player, "Mario Zone 1") if has_pipe_right(state, player):
or state.has_any(["Mushroom", "Fire Flower", "Carrot", if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Mario Zone 1 Midway Bell"], player):
"Mario Zone 1 Midway Bell"], player)) return True
if is_auto_scroll(state, player, "Mario Zone 1"):
return True
return False
def mario_zone_1_midway_bell(state, player): def mario_zone_1_midway_bell(state, player):

View File

@@ -16,7 +16,7 @@ if TYPE_CHECKING:
from . import MM2World from . import MM2World
MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497" MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497"
PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61" PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4"
MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632" MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632"
MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3" MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3"
@@ -404,7 +404,7 @@ def get_base_rom_path(file_name: str = "") -> str:
return file_name return file_name
PRG_OFFSET = 0x8F170 PRG_OFFSET = 0x8ED70
PRG_SIZE = 0x40000 PRG_SIZE = 0x40000

View File

@@ -1,6 +0,0 @@
{
"game": "Ocarina of Time",
"authors": ["espeon65536"],
"world_version": "7.0.0",
"minimum_ap_version": "0.6.4"
}

View File

@@ -1237,7 +1237,7 @@ saffron_gym_warps = [
entrance_only = [ entrance_only = [
"Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F", "Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F",
"Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F", "Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F"
"Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W", "Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W",
"Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House", "Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House",
"Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E", "Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E",

View File

@@ -209,7 +209,7 @@ bread_and_butter_settings = {
OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default, OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default,
OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal, OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal,
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
OPTION_NAME[MissionOrder]: MissionOrder.option_golden_path, OPTION_NAME[MissionOrder]: MissionOrder.option_blitz,
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard, OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
@@ -331,13 +331,12 @@ evil_logic_settings = {
OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal, OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal,
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
OPTION_NAME[MissionOrder]: MissionOrder.option_grid, OPTION_NAME[MissionOrder]: MissionOrder.option_grid,
OPTION_NAME[RequiredTactics]: RequiredTactics.option_any_units, OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced, OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced,
OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines, OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines,
OPTION_NAME[MaximumCampaignSize]: 35, OPTION_NAME[MaximumCampaignSize]: 35,
OPTION_NAME[TwoStartPositions]: TwoStartPositions.option_true,
OPTION_NAME[StarterUnit]: StarterUnit.option_off, OPTION_NAME[StarterUnit]: StarterUnit.option_off,
OPTION_NAME[EnableMorphling]: EnableMorphling.option_true, OPTION_NAME[EnableMorphling]: EnableMorphling.option_true,
OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false, OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false,

View File

@@ -101,7 +101,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) ->
def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int, def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int,
includeuseful: bool, floating: bool) -> bool: includeuseful: bool) -> bool:
# Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres # Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres
if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and
@@ -109,7 +109,7 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: list
return False return False
if buildings[index] == ITEMS.cutter: if buildings[index] == ITEMS.cutter:
if buildings.index(ITEMS.stacker) < index and not floating: if buildings.index(ITEMS.stacker) < index:
return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player) return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player)
else: else:
return can_cut_half(state, player) return can_cut_half(state, player)
@@ -195,38 +195,38 @@ def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool,
# Progressively connect level and upgrade regions # Progressively connect level and upgrade regions
regions[REGIONS.main].connect( regions[REGIONS.main].connect(
regions[REGIONS.levels_1], "Using first level building", regions[REGIONS.levels_1], "Using first level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False, floating)) lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False))
regions[REGIONS.levels_1].connect( regions[REGIONS.levels_1].connect(
regions[REGIONS.levels_2], "Using second level building", regions[REGIONS.levels_2], "Using second level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False, floating)) lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False))
regions[REGIONS.levels_2].connect( regions[REGIONS.levels_2].connect(
regions[REGIONS.levels_3], "Using third level building", regions[REGIONS.levels_3], "Using third level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 2, lambda state: has_logic_list_building(state, player, level_logic_buildings, 2,
early_useful == OPTIONS.buildings_3, floating)) early_useful == OPTIONS.buildings_3))
regions[REGIONS.levels_3].connect( regions[REGIONS.levels_3].connect(
regions[REGIONS.levels_4], "Using fourth level building", regions[REGIONS.levels_4], "Using fourth level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False, floating)) lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False))
regions[REGIONS.levels_4].connect( regions[REGIONS.levels_4].connect(
regions[REGIONS.levels_5], "Using fifth level building", regions[REGIONS.levels_5], "Using fifth level building",
lambda state: has_logic_list_building(state, player, level_logic_buildings, 4, lambda state: has_logic_list_building(state, player, level_logic_buildings, 4,
early_useful == OPTIONS.buildings_5, floating)) early_useful == OPTIONS.buildings_5))
regions[REGIONS.main].connect( regions[REGIONS.main].connect(
regions[REGIONS.upgrades_1], "Using first upgrade building", regions[REGIONS.upgrades_1], "Using first upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False, floating)) lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False))
regions[REGIONS.upgrades_1].connect( regions[REGIONS.upgrades_1].connect(
regions[REGIONS.upgrades_2], "Using second upgrade building", regions[REGIONS.upgrades_2], "Using second upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False, floating)) lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False))
regions[REGIONS.upgrades_2].connect( regions[REGIONS.upgrades_2].connect(
regions[REGIONS.upgrades_3], "Using third upgrade building", regions[REGIONS.upgrades_3], "Using third upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2, lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2,
early_useful == OPTIONS.buildings_3, floating)) early_useful == OPTIONS.buildings_3))
regions[REGIONS.upgrades_3].connect( regions[REGIONS.upgrades_3].connect(
regions[REGIONS.upgrades_4], "Using fourth upgrade building", regions[REGIONS.upgrades_4], "Using fourth upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False, floating)) lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False))
regions[REGIONS.upgrades_4].connect( regions[REGIONS.upgrades_4].connect(
regions[REGIONS.upgrades_5], "Using fifth upgrade building", regions[REGIONS.upgrades_5], "Using fifth upgrade building",
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4, lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4,
early_useful == OPTIONS.buildings_5, floating)) early_useful == OPTIONS.buildings_5))
# Connect Uncolored shapesanity regions to Main # Connect Uncolored shapesanity regions to Main
regions[REGIONS.main].connect( regions[REGIONS.main].connect(

View File

@@ -1,6 +0,0 @@
{
"game": "Stardew Valley",
"authors": ["KaitoKid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"],
"minimum_ap_version": "0.6.4",
"world_version": "6.0.0"
}

View File

@@ -423,7 +423,7 @@ booster_contents: Dict[str, List[str]] = {
"Kaiser Glider", "Kaiser Glider",
"Horus the Black Flame Dragon LV6", "Horus the Black Flame Dragon LV6",
"Luster Dragon", "Luster Dragon",
"Luster Dragon #2", "Luster Dragon #2"
"Spear Dragon", "Spear Dragon",
"Armed Dragon LV3", "Armed Dragon LV3",
"Armed Dragon LV5", "Armed Dragon LV5",
@@ -634,7 +634,7 @@ booster_contents: Dict[str, List[str]] = {
"Mystic Swordsman LV6", "Mystic Swordsman LV6",
"Horus the Black Flame Dragon LV6", "Horus the Black Flame Dragon LV6",
"Horus the Black Flame Dragon LV4", "Horus the Black Flame Dragon LV4",
"Armed Dragon LV3", "Armed Dragon LV3"
"Armed Dragon LV5", "Armed Dragon LV5",
"Silent Swordsman Lv3", "Silent Swordsman Lv3",
"Silent Swordsman Lv5", "Silent Swordsman Lv5",
@@ -750,7 +750,7 @@ booster_contents: Dict[str, List[str]] = {
"Formation Union", "Formation Union",
"Princess Pikeru", "Princess Pikeru",
"Skull Zoma", "Skull Zoma",
"Metal Reflect Slime", "Metal Reflect Slime"
"Level Up!", "Level Up!",
"Howling Insect", "Howling Insect",
"Tribute Doll", "Tribute Doll",

View File

@@ -668,7 +668,7 @@ def only_dragon(state, player):
], player) and (state.count_from_list_unique([ ], player) and (state.count_from_list_unique([
"Luster Dragon", "Luster Dragon",
"Spear Dragon", "Spear Dragon",
"Cave Dragon", "Cave Dragon"
"Armed Dragon LV3", "Armed Dragon LV3",
"Masked Dragon", "Masked Dragon",
"Twin-Headed Behemoth", "Twin-Headed Behemoth",