mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-09 17:13:45 -07:00
Compare commits
21 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e93235f68 | ||
|
|
914a534a3b | ||
|
|
11d18db452 | ||
|
|
00acfe63d4 | ||
|
|
2ac9ab5337 | ||
|
|
2569c9e531 | ||
|
|
946f227226 | ||
|
|
7ead8fdf49 | ||
|
|
f5f554cb3d | ||
|
|
3f2942c599 | ||
|
|
da519e7f73 | ||
|
|
0718ada682 | ||
|
|
f756919dd9 | ||
|
|
406b905dc8 | ||
|
|
91439e0fb0 | ||
|
|
03bd59bff6 | ||
|
|
cf02e1a1aa | ||
|
|
f6d696ea62 | ||
|
|
123acdef23 | ||
|
|
28c7a214dc | ||
|
|
bdae7cd42c |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
39
Fill.py
39
Fill.py
@@ -210,43 +210,12 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
|
||||
_log_fill_progress(name, placed, total)
|
||||
|
||||
if cleanup_required:
|
||||
relevant_locations = multiworld.get_filled_locations(item.player if single_player_placement else None)
|
||||
|
||||
# validate all placements and remove invalid ones
|
||||
cleanup_state = sweep_from_pool(base_state, [], relevant_locations)
|
||||
|
||||
# accessibilty_corrections can clean up any case where locations are unreachable as a result of
|
||||
# a full player's item being on a minimal player's unreachable location.
|
||||
# So, we make a state where we collect all such minimal->full items to check against.
|
||||
changed = False
|
||||
for location in relevant_locations:
|
||||
if location.item is None:
|
||||
continue
|
||||
if location in cleanup_state.locations_checked:
|
||||
continue
|
||||
|
||||
if multiworld.worlds[location.player].options.accessibility == "minimal":
|
||||
if multiworld.worlds[location.item.player].options.accessibility != "minimal":
|
||||
changed |= cleanup_state.collect(location.item, prevent_sweep=True)
|
||||
if changed:
|
||||
cleanup_state.sweep_for_advancements(relevant_locations)
|
||||
|
||||
state = sweep_from_pool(
|
||||
base_state, [], multiworld.get_filled_locations(item.player)
|
||||
if single_player_placement else None)
|
||||
for placement in placements:
|
||||
# If the item's player is minimal, we don't care that it's unreachable.
|
||||
if multiworld.worlds[placement.item.player].options.accessibility == "minimal":
|
||||
continue
|
||||
|
||||
# This item belongs to a full player.
|
||||
# If the location's player is minimal, we don't need to be concerned.
|
||||
# Even if the location is inaccessible, accessibility_corrections will clean this up.
|
||||
if multiworld.worlds[placement.player].options.accessibility == "minimal":
|
||||
continue
|
||||
|
||||
# This is a full player's item on a full player's location.
|
||||
# If this item is unreachable, we have a problem - UNLESS the location is just stuck behind a full player's
|
||||
# item on a minimal player's location. That case will transitively get solved by accessibility_corrections.
|
||||
# This is why we use our special "cleanup_state", not just the maximum exploration state.
|
||||
if not placement.can_reach(cleanup_state):
|
||||
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
|
||||
placement.item.location = None
|
||||
unplaced_items.append(placement.item)
|
||||
placement.item = None
|
||||
|
||||
@@ -1200,16 +1200,17 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
|
||||
hint_status = status # Assign again because we're in a for loop
|
||||
if found:
|
||||
status = HintStatus.HINT_FOUND
|
||||
elif status is None:
|
||||
hint_status = HintStatus.HINT_FOUND
|
||||
elif hint_status is None:
|
||||
if item_flags & ItemClassification.trap:
|
||||
status = HintStatus.HINT_AVOID
|
||||
hint_status = HintStatus.HINT_AVOID
|
||||
else:
|
||||
status = HintStatus.HINT_PRIORITY
|
||||
hint_status = HintStatus.HINT_PRIORITY
|
||||
|
||||
hints.append(
|
||||
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status)
|
||||
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
|
||||
)
|
||||
|
||||
return hints
|
||||
|
||||
40
Utils.py
40
Utils.py
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import typing
|
||||
import builtins
|
||||
@@ -477,7 +478,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||
self.options_module.PlandoText)):
|
||||
self.options_module.PlandoItem, self.options_module.PlandoText)):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||
@@ -1138,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
|
||||
if isinstance(obj, str):
|
||||
return False
|
||||
return isinstance(obj, typing.Iterable)
|
||||
|
||||
|
||||
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||
"""
|
||||
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
|
||||
NOTE: use this with caution because killed threads will not properly clean up.
|
||||
"""
|
||||
|
||||
def _adjust_thread_count(self):
|
||||
# see upstream ThreadPoolExecutor for details
|
||||
import threading
|
||||
import weakref
|
||||
from concurrent.futures.thread import _worker
|
||||
|
||||
if self._idle_semaphore.acquire(timeout=0):
|
||||
return
|
||||
|
||||
def weakref_cb(_, q=self._work_queue):
|
||||
q.put(None)
|
||||
|
||||
num_threads = len(self._threads)
|
||||
if num_threads < self._max_workers:
|
||||
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
|
||||
t = threading.Thread(
|
||||
name=thread_name,
|
||||
target=_worker,
|
||||
args=(
|
||||
weakref.ref(self, weakref_cb),
|
||||
self._work_queue,
|
||||
self._initializer,
|
||||
self._initargs,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
self._threads.add(t)
|
||||
# NOTE: don't add to _threads_queues so we don't block on shutdown
|
||||
|
||||
@@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException):
|
||||
logging.exception(e)
|
||||
|
||||
|
||||
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
|
||||
def _mp_gen_game(
|
||||
gen_options: dict,
|
||||
meta: dict[str, Any] | None = None,
|
||||
owner=None,
|
||||
sid=None,
|
||||
timeout: int|None = None,
|
||||
) -> PrimaryKey | None:
|
||||
from setproctitle import setproctitle
|
||||
|
||||
setproctitle(f"Generator ({sid})")
|
||||
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
|
||||
setproctitle(f"Generator (idle)")
|
||||
return res
|
||||
try:
|
||||
return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
|
||||
finally:
|
||||
setproctitle(f"Generator (idle)")
|
||||
|
||||
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
|
||||
try:
|
||||
meta = json.loads(generation.meta)
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(_mp_gen_game, (options,),
|
||||
{"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
pool.apply_async(
|
||||
_mp_gen_game,
|
||||
(options,),
|
||||
{
|
||||
"meta": meta,
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner,
|
||||
"timeout": timeout,
|
||||
},
|
||||
handle_generation_success,
|
||||
handle_generation_failure,
|
||||
)
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
@@ -135,6 +149,7 @@ def autogen(config: dict):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
|
||||
initargs=(config,), maxtasksperchild=10) as generator_pool:
|
||||
job_time = config["JOB_TIME"]
|
||||
with db_session:
|
||||
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
|
||||
|
||||
@@ -145,7 +160,7 @@ def autogen(config: dict):
|
||||
if sid:
|
||||
generation.delete()
|
||||
else:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
|
||||
commit()
|
||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||
@@ -157,7 +172,7 @@ def autogen(config: dict):
|
||||
generation for generation in Generation
|
||||
if generation.state == STATE_QUEUED).for_update()
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
launch_generator(generator_pool, generation, timeout=job_time)
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autogen reports as already running, not starting another.")
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
|
||||
from BaseClasses import get_seed, seeddigits
|
||||
from Generate import PlandoOptions, handle_name, mystery_argparse
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__, restricted_dumps
|
||||
from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
|
||||
from WebHostLib import app
|
||||
from settings import ServerOptions, GeneratorOptions
|
||||
from .check import get_yaml_data, roll_options
|
||||
@@ -107,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||
else:
|
||||
try:
|
||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||
meta=meta, owner=session["_id"].int)
|
||||
meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
|
||||
except BaseException as e:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
@@ -118,7 +118,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
|
||||
|
||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None):
|
||||
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
|
||||
if meta is None:
|
||||
meta = {}
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
|
||||
|
||||
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
|
||||
return publish_parts(text, writer='html', settings=None, settings_overrides={
|
||||
'raw_enable': False,
|
||||
'file_insertion_enabled': False,
|
||||
'output_encoding': 'unicode'
|
||||
|
||||
@@ -4,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'
|
||||
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
|
||||
|
||||
@@ -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`
|
||||
|
||||
3
setup.py
3
setup.py
@@ -381,7 +381,8 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||
world_directory = self.libfolder / "worlds" / file_name
|
||||
if os.path.isfile(world_directory / "archipelago.json"):
|
||||
manifest = json.load(open(world_directory / "archipelago.json"))
|
||||
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
|
||||
assert "game" in manifest, (
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import PlandoOptions
|
||||
from Options import ItemLinks, Choice
|
||||
from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
|
||||
from Utils import restricted_dumps
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
|
||||
for link in item_links.values():
|
||||
self.assertEqual(link.value[0], item_link_group[0])
|
||||
|
||||
def test_pickle_dumps(self):
|
||||
"""Test options can be pickled into database for WebHost generation"""
|
||||
def test_pickle_dumps_default(self):
|
||||
"""Test that default option values can be pickled into database for WebHost generation"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||
@@ -81,3 +81,23 @@ class TestOptions(unittest.TestCase):
|
||||
restricted_dumps(option.from_any(option.default))
|
||||
if issubclass(option, Choice) and option.default in option.name_lookup:
|
||||
restricted_dumps(option.from_text(option.name_lookup[option.default]))
|
||||
|
||||
def test_pickle_dumps_plando(self):
|
||||
"""Test that plando options using containers of a custom type can be pickled"""
|
||||
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
|
||||
class TestPlandoConnections(PlandoConnections):
|
||||
entrances = {"An Entrance"}
|
||||
exits = {"An Exit"}
|
||||
plando_connection_value = PlandoConnections(
|
||||
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
|
||||
)
|
||||
|
||||
plando_values = {
|
||||
"PlandoConnections": plando_connection_value,
|
||||
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
|
||||
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
|
||||
}
|
||||
|
||||
for option_key, value in plando_values.items():
|
||||
with self.subTest(option=option_key):
|
||||
restricted_dumps(value)
|
||||
|
||||
14
test/utils/test_daemon_thread_pool.py
Normal file
14
test/utils/test_daemon_thread_pool.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import unittest
|
||||
|
||||
from Utils import DaemonThreadPoolExecutor
|
||||
|
||||
|
||||
class DaemonThreadPoolExecutorTest(unittest.TestCase):
|
||||
def test_is_daemon(self) -> None:
|
||||
def run() -> None:
|
||||
pass
|
||||
|
||||
with DaemonThreadPoolExecutor(1) as executor:
|
||||
executor.submit(run)
|
||||
|
||||
self.assertTrue(next(iter(executor._threads)).daemon)
|
||||
@@ -271,7 +271,8 @@ if not is_frozen():
|
||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||
world_directory = os.path.join("worlds", file_name)
|
||||
if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
|
||||
manifest = json.load(open(os.path.join(world_directory, "archipelago.json")))
|
||||
with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
|
||||
assert "game" in manifest, (
|
||||
f"World directory {world_directory} has an archipelago.json manifest file, but it"
|
||||
|
||||
@@ -122,7 +122,8 @@ for world_source in world_sources:
|
||||
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
|
||||
for file in filenames:
|
||||
if file.endswith("archipelago.json"):
|
||||
manifest = json.load(open(os.path.join(dirpath, file), "r"))
|
||||
with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
|
||||
manifest = json.load(manifest_file)
|
||||
break
|
||||
if manifest:
|
||||
break
|
||||
|
||||
@@ -20,6 +20,7 @@ class CivVIBoostData:
|
||||
Prereq: List[str]
|
||||
PrereqRequiredCount: int
|
||||
Classification: str
|
||||
EraRequired: bool = False
|
||||
|
||||
|
||||
class GoodyHutRewardData(TypedDict):
|
||||
|
||||
@@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
|
||||
location = CivVILocationData(
|
||||
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
|
||||
)
|
||||
era_locations["ERA_ANCIENT"][boost.Type] = location
|
||||
# If EraRequired is True, place the boost in its actual era
|
||||
# Otherwise, place it in ERA_ANCIENT for early access
|
||||
target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT"
|
||||
era_locations[target_era][boost.Type] = location
|
||||
id_base += 1
|
||||
|
||||
return era_locations
|
||||
|
||||
@@ -210,8 +210,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_SQUARE_RIGGING",
|
||||
"ERA_RENAISSANCE",
|
||||
["TECH_GUNPOWDER"],
|
||||
1,
|
||||
["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -252,15 +252,15 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_BALLISTICS",
|
||||
"ERA_INDUSTRIAL",
|
||||
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"],
|
||||
2,
|
||||
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_MILITARY_SCIENCE",
|
||||
"ERA_INDUSTRIAL",
|
||||
["TECH_STIRRUPS"],
|
||||
1,
|
||||
["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -301,8 +301,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_REPLACEABLE_PARTS",
|
||||
"ERA_MODERN",
|
||||
["TECH_MILITARY_SCIENCE"],
|
||||
1,
|
||||
["TECH_MILITARY_SCIENCE", "TECH_MINING"],
|
||||
2,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -343,8 +343,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_ADVANCED_FLIGHT",
|
||||
"ERA_ATOMIC",
|
||||
["TECH_FLIGHT"],
|
||||
1,
|
||||
["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -436,8 +436,8 @@ boosts: List[CivVIBoostData] = [
|
||||
CivVIBoostData(
|
||||
"BOOST_TECH_COMPOSITES",
|
||||
"ERA_INFORMATION",
|
||||
["TECH_COMBUSTION"],
|
||||
1,
|
||||
["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"],
|
||||
3,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -470,7 +470,7 @@ boosts: List[CivVIBoostData] = [
|
||||
"TECH_ELECTRICITY",
|
||||
"TECH_NUCLEAR_FISSION",
|
||||
],
|
||||
1,
|
||||
4,
|
||||
"DEFAULT",
|
||||
),
|
||||
CivVIBoostData(
|
||||
@@ -651,10 +651,11 @@ boosts: List[CivVIBoostData] = [
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_FEUDALISM",
|
||||
"ERA_MEDIEVAL",
|
||||
"ERA_CLASSICAL",
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True,
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_CIVIL_SERVICE",
|
||||
@@ -662,6 +663,7 @@ boosts: List[CivVIBoostData] = [
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True,
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_MERCENARIES",
|
||||
@@ -790,6 +792,7 @@ boosts: List[CivVIBoostData] = [
|
||||
[],
|
||||
0,
|
||||
"DEFAULT",
|
||||
True
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_CONSERVATION",
|
||||
@@ -885,6 +888,7 @@ boosts: List[CivVIBoostData] = [
|
||||
["TECH_ROCKETRY"],
|
||||
1,
|
||||
"DEFAULT",
|
||||
True
|
||||
),
|
||||
CivVIBoostData(
|
||||
"BOOST_CIVIC_GLOBALIZATION",
|
||||
|
||||
@@ -105,3 +105,78 @@ class TestBoostsanityExcluded(CivVITestBase):
|
||||
if "BOOST" in location.name:
|
||||
found_locations += 1
|
||||
self.assertEqual(found_locations, 0)
|
||||
|
||||
|
||||
class TestBoostsanityEraRequired(CivVITestBase):
|
||||
options = {
|
||||
"boostsanity": "true",
|
||||
"progression_style": "none",
|
||||
"shuffle_goody_hut_rewards": "false",
|
||||
}
|
||||
|
||||
def test_era_required_boosts_not_accessible_early(self) -> None:
|
||||
# BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL
|
||||
# It should NOT be accessible in Ancient era
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
|
||||
# BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL
|
||||
# It should NOT be accessible in Ancient era
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC
|
||||
# It should NOT be accessible in Ancient era
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE"))
|
||||
|
||||
# Regular boosts without EraRequired should be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING"))
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION"))
|
||||
|
||||
def test_era_required_boosts_accessible_in_correct_era(self) -> None:
|
||||
# Collect items to reach Classical era
|
||||
self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing",
|
||||
"Irrigation", "Sailing", "Animal Husbandry",
|
||||
"State Workforce", "Foreign Trade"])
|
||||
|
||||
# BOOST_CIVIC_FEUDALISM should now be accessible in Classical era
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
|
||||
# BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial)
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# Collect more items to reach Industrial era
|
||||
self.collect_all_but(["TECH_ROCKETRY"])
|
||||
|
||||
# Now BOOST_CIVIC_URBANIZATION should be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
|
||||
class TestBoostsanityEraRequiredWithProgression(CivVITestBase):
|
||||
options = {
|
||||
"boostsanity": "true",
|
||||
"progression_style": "eras_and_districts",
|
||||
"shuffle_goody_hut_rewards": "false",
|
||||
}
|
||||
|
||||
def test_era_required_with_progressive_eras(self) -> None:
|
||||
# Collect all items except Progressive Era
|
||||
self.collect_all_but(["Progressive Era"])
|
||||
|
||||
# Even with all other items, era-required boosts should not be accessible
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# Collect enough Progressive Era items to reach Classical (needs 2)
|
||||
self.collect(self.get_item_by_name("Progressive Era"))
|
||||
self.collect(self.get_item_by_name("Progressive Era"))
|
||||
|
||||
# BOOST_CIVIC_FEUDALISM should now be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
|
||||
|
||||
# But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total)
|
||||
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
# Collect 3 more Progressive Era items to reach Industrial
|
||||
self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"])
|
||||
|
||||
# Now BOOST_CIVIC_URBANIZATION should be accessible
|
||||
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
|
||||
|
||||
@@ -2,6 +2,7 @@ import csv
|
||||
import enum
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from functools import reduce
|
||||
from random import Random
|
||||
from typing import Dict, List, Set
|
||||
|
||||
@@ -61,7 +62,7 @@ def load_item_csv():
|
||||
item_reader = csv.DictReader(file)
|
||||
for item in item_reader:
|
||||
id = int(item["id"]) if item["id"] else None
|
||||
classification = ItemClassification[item["classification"]]
|
||||
classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")})
|
||||
groups = {Group[group] for group in item["groups"].split(",") if group}
|
||||
items.append(ItemData(id, item["name"], classification, groups))
|
||||
return items
|
||||
|
||||
@@ -22,7 +22,7 @@ id,name,classification,groups
|
||||
20,Wall Jump Pack,progression,"DLC,Freemium"
|
||||
21,Health Bar Pack,useful,"DLC,Freemium"
|
||||
22,Parallax Pack,filler,"DLC,Freemium"
|
||||
23,Harmless Plants Pack,progression,"DLC,Freemium"
|
||||
23,Harmless Plants Pack,"progression,trap","DLC,Freemium"
|
||||
24,Death of Comedy Pack,progression,"DLC,Freemium"
|
||||
25,Canadian Dialog Pack,filler,"DLC,Freemium"
|
||||
26,DLC NPC Pack,progression,"DLC,Freemium"
|
||||
|
||||
|
@@ -16,6 +16,7 @@ logger = logging.getLogger("Client")
|
||||
|
||||
|
||||
rom_name_location = 0x07FFE3
|
||||
player_name_location = 0x07BCC0
|
||||
locations_array_start = 0x200
|
||||
locations_array_length = 0x100
|
||||
items_obtained = 0x03
|
||||
@@ -111,6 +112,12 @@ class FF1Client(BizHawkClient):
|
||||
|
||||
return True
|
||||
|
||||
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
|
||||
auth_raw = (await bizhawk.read(
|
||||
ctx.bizhawk_ctx,
|
||||
[(player_name_location, 0x40, self.rom)]))[0]
|
||||
ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip()
|
||||
|
||||
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
|
||||
if ctx.server is None:
|
||||
return
|
||||
@@ -204,7 +211,7 @@ class FF1Client(BizHawkClient):
|
||||
write_list.append((location, [0], self.sram))
|
||||
elif current_item_name in no_overworld_items:
|
||||
if current_item_name == "Sigil":
|
||||
location = 0x28
|
||||
location = 0x2B
|
||||
else:
|
||||
location = 0x12
|
||||
write_list.append((location, [1], self.sram))
|
||||
|
||||
@@ -253,5 +253,17 @@
|
||||
"CubeBot": 529,
|
||||
"Sarda": 525,
|
||||
"Fairy": 531,
|
||||
"Lefein": 527
|
||||
"Lefein": 527,
|
||||
"DeepDungeon32B_Chest144": 401,
|
||||
"DeepDungeon30B_Chest145": 402,
|
||||
"DeepDungeon29B_Chest146": 403,
|
||||
"DeepDungeon29B_Chest147": 404,
|
||||
"DeepDungeon40B_Chest186": 443,
|
||||
"DeepDungeon38B_Chest188": 445,
|
||||
"DeepDungeon36B_Chest189": 446,
|
||||
"DeepDungeon33B_Chest190": 447,
|
||||
"DeepDungeon40B_Chest191": 448,
|
||||
"DeepDungeon41B_Chest192": 449,
|
||||
"DeepDungeon34B_Chest193": 450,
|
||||
"DeepDungeon39B_Chest194": 451
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* A legal copy of Hollow Knight.
|
||||
* Steam, Gog, and Xbox Game Pass versions of the game are supported.
|
||||
* Windows, Mac, and Linux (including Steam Deck) are supported.
|
||||
|
||||
**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods.
|
||||
|
||||
## Installing the Archipelago Mod using Lumafly
|
||||
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* Tener una copia legal de Hollow Knight.
|
||||
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles
|
||||
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
|
||||
|
||||
**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods)
|
||||
usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y
|
||||
similares, en vez de sitios web como Nexus Mods.
|
||||
|
||||
## Instalación del mod de Archipelago con Lumafly
|
||||
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
|
||||
@@ -61,4 +65,4 @@ de Archipelago para generar un YAML usando una interfaz gráfica.
|
||||
## Consejos y otros comandos
|
||||
Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la
|
||||
[guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto,
|
||||
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||
que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* Uma cópia legal de Hollow Knight.
|
||||
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
|
||||
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
|
||||
|
||||
**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods)
|
||||
usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes,
|
||||
ao invés de sites como o Nexus Mods.
|
||||
|
||||
## Instalando o mod Archipelago Mod usando Lumafly
|
||||
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
|
||||
|
||||
6
worlds/kh2/archipelago.json
Normal file
6
worlds/kh2/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Kingdom Hearts 2",
|
||||
"authors": [ "JaredWeakStrike" ],
|
||||
"minimum_ap_version": "0.6.3",
|
||||
"world_version": "2.0.0"
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
6
worlds/oot/archipelago.json
Normal file
6
worlds/oot/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Ocarina of Time",
|
||||
"authors": ["espeon65536"],
|
||||
"world_version": "7.0.0",
|
||||
"minimum_ap_version": "0.6.4"
|
||||
}
|
||||
@@ -209,7 +209,7 @@ bread_and_butter_settings = {
|
||||
OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default,
|
||||
OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal,
|
||||
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
|
||||
OPTION_NAME[MissionOrder]: MissionOrder.option_blitz,
|
||||
OPTION_NAME[MissionOrder]: MissionOrder.option_golden_path,
|
||||
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
|
||||
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
|
||||
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
||||
@@ -331,12 +331,13 @@ evil_logic_settings = {
|
||||
OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal,
|
||||
OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys,
|
||||
OPTION_NAME[MissionOrder]: MissionOrder.option_grid,
|
||||
OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard,
|
||||
OPTION_NAME[RequiredTactics]: RequiredTactics.option_any_units,
|
||||
OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys,
|
||||
OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one,
|
||||
OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced,
|
||||
OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines,
|
||||
OPTION_NAME[MaximumCampaignSize]: 35,
|
||||
OPTION_NAME[TwoStartPositions]: TwoStartPositions.option_true,
|
||||
OPTION_NAME[StarterUnit]: StarterUnit.option_off,
|
||||
OPTION_NAME[EnableMorphling]: EnableMorphling.option_true,
|
||||
OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false,
|
||||
|
||||
@@ -101,7 +101,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) ->
|
||||
|
||||
|
||||
def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int,
|
||||
includeuseful: bool) -> bool:
|
||||
includeuseful: bool, floating: bool) -> bool:
|
||||
|
||||
# Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres
|
||||
if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and
|
||||
@@ -109,7 +109,7 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: list
|
||||
return False
|
||||
|
||||
if buildings[index] == ITEMS.cutter:
|
||||
if buildings.index(ITEMS.stacker) < index:
|
||||
if buildings.index(ITEMS.stacker) < index and not floating:
|
||||
return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player)
|
||||
else:
|
||||
return can_cut_half(state, player)
|
||||
@@ -195,38 +195,38 @@ def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool,
|
||||
# Progressively connect level and upgrade regions
|
||||
regions[REGIONS.main].connect(
|
||||
regions[REGIONS.levels_1], "Using first level building",
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False))
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False, floating))
|
||||
regions[REGIONS.levels_1].connect(
|
||||
regions[REGIONS.levels_2], "Using second level building",
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False))
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False, floating))
|
||||
regions[REGIONS.levels_2].connect(
|
||||
regions[REGIONS.levels_3], "Using third level building",
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 2,
|
||||
early_useful == OPTIONS.buildings_3))
|
||||
early_useful == OPTIONS.buildings_3, floating))
|
||||
regions[REGIONS.levels_3].connect(
|
||||
regions[REGIONS.levels_4], "Using fourth level building",
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False))
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False, floating))
|
||||
regions[REGIONS.levels_4].connect(
|
||||
regions[REGIONS.levels_5], "Using fifth level building",
|
||||
lambda state: has_logic_list_building(state, player, level_logic_buildings, 4,
|
||||
early_useful == OPTIONS.buildings_5))
|
||||
early_useful == OPTIONS.buildings_5, floating))
|
||||
regions[REGIONS.main].connect(
|
||||
regions[REGIONS.upgrades_1], "Using first upgrade building",
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False))
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False, floating))
|
||||
regions[REGIONS.upgrades_1].connect(
|
||||
regions[REGIONS.upgrades_2], "Using second upgrade building",
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False))
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False, floating))
|
||||
regions[REGIONS.upgrades_2].connect(
|
||||
regions[REGIONS.upgrades_3], "Using third upgrade building",
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2,
|
||||
early_useful == OPTIONS.buildings_3))
|
||||
early_useful == OPTIONS.buildings_3, floating))
|
||||
regions[REGIONS.upgrades_3].connect(
|
||||
regions[REGIONS.upgrades_4], "Using fourth upgrade building",
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False))
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False, floating))
|
||||
regions[REGIONS.upgrades_4].connect(
|
||||
regions[REGIONS.upgrades_5], "Using fifth upgrade building",
|
||||
lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4,
|
||||
early_useful == OPTIONS.buildings_5))
|
||||
early_useful == OPTIONS.buildings_5, floating))
|
||||
|
||||
# Connect Uncolored shapesanity regions to Main
|
||||
regions[REGIONS.main].connect(
|
||||
|
||||
6
worlds/stardew_valley/archipelago.json
Normal file
6
worlds/stardew_valley/archipelago.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"game": "Stardew Valley",
|
||||
"authors": ["KaitoKid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"],
|
||||
"minimum_ap_version": "0.6.4",
|
||||
"world_version": "6.0.0"
|
||||
}
|
||||
Reference in New Issue
Block a user