Compare commits

...

35 Commits

Author SHA1 Message Date
NewSoupVi
f5bb0f13a0 Update kvui.py 2025-10-25 12:31:23 +02:00
NewSoupVi
cbb1535242 kvui: Fix audio on Linux 2025-10-25 12:20:58 +02:00
NewSoupVi
643f61e7f4 Core: Add a ruff.toml to the root directory (#5259)
* Add a ruff.toml to the root directory

* spell out C901

* Add target version

* Add some more of the suggested rules

* ignore PLC0415

* TC is bad

* ignore B0011

* ignore N818

* Ignore some more rules

* Add PLC1802 to ignore list

* Update ruff.toml

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>

* oops

* R to RET and RSC

* oops

* Py311

* Update ruff.toml

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-10-25 00:19:42 +02:00
black-sliver
6b91ffecf1 WebHost: add missing docutils requirement ... (#5583)
... and update it to latest.
This is being used in WebHostLib.options directly.
A recent change bumped our required version, so this is actually a fix.
2025-10-24 00:55:10 +02:00
black-sliver
4f7f092b9b setup: check if the sign host is on a local network (#5501)
Could have a really bad timeout if it goes through default route and packet is dropped.
2025-10-24 00:54:27 +02:00
gaithern
df3c6b7980 KH1: Add specified encoding to file output from Client to avoid crashes with non ASCII characters (#5584)
* Fix Slot 2 Level Checks description

* Fix encoding issue
2025-10-23 23:01:02 +02:00
threeandthreee
19839399e5 LADX: stealing logic option (#3965)
* implement StealingInLogic option

* fix ladxr setting

* adjust docs

* option to disable stealing

* indicate disabled stealing with shopkeeper dialog

* merge upstream/main

* Revert "merge upstream/main"

This reverts commit c91d2d6b29.

* fix

* stealing in patch

* logic reorder and fix

sword to front for readability, but also can_farm condition was missing
2025-10-23 22:11:41 +02:00
CookieCat
4847be98d2 AHIT: Fix death link timestamps being incorrect (#5404) 2025-10-23 05:30:46 +02:00
black-sliver
3105320038 Test: check fields in world source manifest (#5558)
* Test: check game in world manifest

* Update test/general/test_world_manifest.py

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

* Test: rework finding expected manifest location

* Test: fix doc comment

* Test: fix wrong custom_worlds path in test_world_manifest

Also simplifies the way we find ./worlds/.

* Test: make test_world_manifest easier to extend

* Test: check world_version in world manifest

according to docs/apworld specification.md

* Test: check no container version in source world manifest

according what was added to docs/apworld specification.md in PR 5509

* Test: better assertion messages in test_world_manifest.py

* Test: fix wording in world source manifest

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-10-22 01:52:44 +02:00
Silvris
e8c8b0dbc5 MM2: fix Proteus reading #5575 2025-10-21 19:10:39 +02:00
Duck
c199775c48 Pokemon RB: Fix likely unintended concatenation #5566 2025-10-20 21:48:17 +02:00
Duck
d2bf7fdaf7 AHiT: Fix likely unintended concatenation #5565 2025-10-20 21:47:49 +02:00
Duck
621ec274c3 Yugioh: Fix likely unintended concatenations (#5567)
* Fix likely unintended concatenations

* Yeah that makes sense why I thought there were more here
2025-10-20 21:47:16 +02:00
NewSoupVi
7cd73e2710 WebHost: Fix generate argparse with --config-override + add autogen unit tests so we can test that (#5541)
* Fix webhost argparse with extra args

* accidentally added line

* WebHost: fix some typing

B64 url conversion is used in test/hosting,
so it felt appropriate to include this here.

* Test: Hosting: also test autogen

* Test: Hosting: simplify stop_* and leave a note about Windows compat

* Test: Hosting: fix formatting error

* Test: Hosting: add limitted Windows support

There are actually some differences with MP on Windows
that make it impossible to run this in CI.

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-20 17:40:32 +02:00
NewSoupVi
708df4d1e2 WebHost: Fix flask-compress to 1.18 for Python 3.11 (to get CI to pass again) (#5573)
From Discord:

Well, flask-compress updated and now our 3.11 CI is failing

Why? They switched to a lib called backports.zstd
And 3.11 pkg_resources can't handle that.

pip finds it. But in our ModuleUpdate.py, we first pkg_resources.require packages, and this fails. I can't reproduce this locally yet, but in CI, it seems like even though backports.zstd is installed, it still fails on it and prompts installing it over and over in every unit test
Now what do we do :KEKW:
Black Sliver suggested pinning flask-compress for 3.11
But I would just like to point out that this means we can't unpin it until we drop 3.11
the real thing is we probably need to move away from pkg_resources? lol 
since it's been deprecated literally since the oldest version we support
2025-10-20 17:06:07 +02:00
black-sliver
914a534a3b WebHost: fix gen timeout/exception resource handling (#5540)
* WebHost: reset Generator proc title on error

* WebHost: fix shutting down autogen

This is still not perfect but solves some of the issues.

* WebHost: properly propagate JOB_TIME

* WebHost: handle autogen shutdown
2025-10-20 09:16:29 +02:00
NewSoupVi
11d18db452 Docs: APWorld documentation, make a distinction between APWorld and .apworld (#5509)
* APWorld docs: Make a distinction between APWorld and .apworld

* Update apworld specification.md

* Update apworld specification.md

* Be more anal about the launcher component

* Update apworld specification.md

* Update apworld specification.md
2025-10-19 09:05:34 +02:00
Nicholas Saylor
00acfe63d4 WebHost: Update publish_parts parameters (#5544)
old name is deprecated and new name allows both writer instance or alias/name.
2025-10-19 03:40:25 +02:00
Fafale
2ac9ab5337 Docs: add warning about BepInEx to HK translated setup guides (#5554)
* Update HK pt-br setup to add warning about BepInEx

* Update HK spanish setup guide to add warning about BepInEx
2025-10-19 03:36:35 +02:00
Benny D
2569c9e531 DLC Quest: Enable multi-classification items (#5552)
* implement prog trap item (thanks stardew)

* oops that's wrong

* okay this is right
2025-10-19 03:30:24 +02:00
Rosalie
946f227226 [FF1] Added Deep Dungeon locations to locations.json so they exist in the datapackage (#5392)
* Added DD locations to locations.json so they exist in the datapackage.

* Update worlds/ff1/data/locations.json

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update worlds/ff1/data/locations.json

Forgot trailing commas aren't allowed in JSON.

Co-authored-by: qwint <qwint.42@gmail.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
2025-10-17 16:44:11 +02:00
Carter Hesterman
7ead8fdf49 Civ 6: Add era requirements for boosts and update boost prereqs (#5296)
* Resolve #5136

* Resolves #5210
2025-10-17 16:35:44 +02:00
Rosalie
f5f554cb3d [FF1] Client fix and improvement (#5390)
* FF1 Client fixes.

* Strip leading/trailing spaces from rom-stored player name.

* FF1R encodes the name as utf-8, as it happens.

* UTF-8 is four bytes per character, so we need 64 bytes for the name, not 16.
2025-10-17 16:34:10 +02:00
Alchav
3f2942c599 Super Mario Land 2: Logic fixes #5258
Co-authored-by: alchav <alchav@jalchavware.com>
2025-10-17 16:32:58 +02:00
Snarky
da519e7f73 SC2: fix incorrect preset option (#5551)
* SC2: fix incorrect preset option

* SC2: fix incorrect evil logic preset option

---------

Co-authored-by: Snarky <sparkykueken@gmail.com>
2025-10-17 16:30:05 +02:00
Duck
0718ada682 Core: Allow PlandoItems to be pickled (#5335)
* Add Options.PlandoItem

* Remove worlds.generic.PlandoItem handling

* Add plando pickling test

* Revert old PlandoItem cleanup

* Deprecate old PlandoItem

* Change to warning message

* Use deprecated decorator
2025-10-17 03:20:34 +02:00
Duck
f756919dd9 CI: Add worlds manifests to build action trigger (#5555)
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-16 23:58:12 +02:00
Jérémie Bolduc
406b905dc8 Stardew Valley: Add archipelago.json (#5535)
* add apworld manifest

* add world version
2025-10-16 22:23:23 +02:00
JaredWeakStrike
91439e0fb0 KH2: Manifest eletric boogaloo (#5556)
* manifest file

* x y z for world version

* Update archipelago.json
2025-10-16 20:25:11 +02:00
RoobyRoo
03bd59bff6 Ocarina of Time: Create manifest (#5536)
* Create archipelago.json

* Sure, let's call it 7.0.0

* Update archipelago.json

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-16 11:48:04 +02:00
BlastSlimey
cf02e1a1aa shapez: Fix floating layers logic error #5263 2025-10-15 23:41:15 +02:00
JaredWeakStrike
f6d696ea62 KH2: Manifest File (#5553)
* manifest file

* x y z for world version
2025-10-15 23:40:21 +02:00
BadMagic100
123acdef23 Docs: warn HK users not to use BepInEx #5550 2025-10-15 13:35:00 +02:00
Nicholas Saylor
28c7a214dc Core: Use Better Practices Accessing Manifests (#5543)
* Close manifest files

* Name explicit encoding
2025-10-15 01:09:05 +02:00
NewSoupVi
bdae7cd42c MultiServer: Fix hinting multi-copy items bleeding found status (#5547)
* fix hinting multi-copy items bleeding found status

* reword
2025-10-14 20:44:01 +02:00
51 changed files with 681 additions and 180 deletions

View File

@@ -9,12 +9,14 @@ 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:

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.")

View File

@@ -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>')

View File

@@ -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'

View File

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

11
kvui.py
View File

@@ -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
View 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.
]

View File

@@ -146,7 +146,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 +390,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"

View File

@@ -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)

View 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.",
)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View 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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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",
]

View File

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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"))

View File

@@ -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

View File

@@ -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"
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 progression,trap 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,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))

View File

@@ -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
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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()

View File

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

View File

@@ -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

View File

@@ -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")

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.
[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":

View File

@@ -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

View File

@@ -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",

View File

@@ -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):

View File

@@ -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

View File

@@ -0,0 +1,6 @@
{
"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 = [
"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",

View File

@@ -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,

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,
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(

View 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"
}

View File

@@ -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",

View File

@@ -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",