Compare commits

...

21 Commits

Author SHA1 Message Date
NewSoupVi
9e93235f68 WebHost: Fix flask-compress to 1.18 for Python 3.11 (to get CI to pass again)
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 15:55:41 +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
31 changed files with 356 additions and 110 deletions

View File

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

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] found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hint_status = status # Assign again because we're in a for loop
if found: if found:
status = HintStatus.HINT_FOUND hint_status = HintStatus.HINT_FOUND
elif status is None: elif hint_status is None:
if item_flags & ItemClassification.trap: if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID hint_status = HintStatus.HINT_AVOID
else: else:
status = HintStatus.HINT_PRIORITY hint_status = HintStatus.HINT_PRIORITY
hints.append( hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status) Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
) )
return hints return hints

View File

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

View File

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

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 from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions from settings import ServerOptions, GeneratorOptions
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
@@ -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) meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
except BaseException as e: except BaseException as e:
from .autolauncher import handle_generation_failure from .autolauncher import handle_generation_failure
handle_generation_failure(e) handle_generation_failure(e)
@@ -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): def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
if meta is None: if meta is None:
meta = {} meta = {}
@@ -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"]) ERmain(args, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task) thread = thread_pool.submit(task)
try: try:
return thread.result(app.config["JOB_TIME"]) return thread.result(timeout)
except concurrent.futures.TimeoutError as e: except concurrent.futures.TimeoutError as e:
if sid: if sid:
with db_session: with db_session:
@@ -189,6 +190,9 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
format_exception(e)) format_exception(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
except (KeyboardInterrupt, SystemExit):
# don't update db, retry next time
raise
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:
@@ -200,6 +204,11 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
raise raise
finally:
# free resources claimed by thread pool, if possible
# NOTE: Timeout depends on the process being killed at some point
# since we can't actually cancel a running gen at the moment.
thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/<suuid:seed>') @app.route('/wait/<suuid:seed>')

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_name='html', settings=None, settings_overrides={ return publish_parts(text, writer='html', settings=None, settings_overrides={
'raw_enable': False, 'raw_enable': False,
'file_insertion_enabled': False, 'file_insertion_enabled': False,
'output_encoding': 'unicode' 'output_encoding': 'unicode'

View File

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

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.
Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed). These are called "APWorlds".
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
See [world api.md](world%20api.md) for details. See [world api.md](world%20api.md) for details.
APWorlds can either be a folder, or they can be packaged as an .apworld file.
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld` ## .apworld File Format
file into the worlds folder.
**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+! The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution
by placing a `*.apworld` file into the worlds folder.
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
## File Format
apworld files are zip archives, all lower case, with the file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
**Warning:** `.apworld` files have to be all lower case,
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
## Metadata ## Metadata
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive. Metadata about the APWorld is defined in an `archipelago.json` file.
The current format version has at minimum:
If the APWorld is a folder, the only required field is "game":
```json ```json
{ {
"version": 7, "game": "Game Name"
"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`

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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,"DLC,Freemium" 23,Harmless Plants Pack,"progression,trap","DLC,Freemium"
24,Death of Comedy Pack,progression,"DLC,Freemium" 24,Death of Comedy Pack,progression,"DLC,Freemium"
25,Canadian Dialog Pack,filler,"DLC,Freemium" 25,Canadian Dialog Pack,filler,"DLC,Freemium"
26,DLC NPC Pack,progression,"DLC,Freemium" 26,DLC NPC Pack,progression,"DLC,Freemium"
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 rom_name_location = 0x07FFE3
player_name_location = 0x07BCC0
locations_array_start = 0x200 locations_array_start = 0x200
locations_array_length = 0x100 locations_array_length = 0x100
items_obtained = 0x03 items_obtained = 0x03
@@ -111,6 +112,12 @@ class FF1Client(BizHawkClient):
return True return True
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
auth_raw = (await bizhawk.read(
ctx.bizhawk_ctx,
[(player_name_location, 0x40, self.rom)]))[0]
ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip()
async def game_watcher(self, ctx: "BizHawkClientContext") -> None: async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.server is None: if ctx.server is None:
return return
@@ -204,7 +211,7 @@ class FF1Client(BizHawkClient):
write_list.append((location, [0], self.sram)) write_list.append((location, [0], self.sram))
elif current_item_name in no_overworld_items: elif current_item_name in no_overworld_items:
if current_item_name == "Sigil": if current_item_name == "Sigil":
location = 0x28 location = 0x2B
else: else:
location = 0x12 location = 0x12
write_list.append((location, [1], self.sram)) write_list.append((location, [1], self.sram))

View File

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

View File

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

View File

@@ -6,6 +6,8 @@
* 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,6 +6,10 @@
* 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,6 +6,10 @@
* 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

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

View File

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

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

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

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

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