diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 80aaf70c215e..dd88d8d7d7bf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -40,6 +40,10 @@ jobs:
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
+ if ( $? -eq $false ) {
+ Write-Error "setup.py failed!"
+ exit 1
+ }
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
@@ -49,12 +53,6 @@ jobs:
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- - name: Store 7z
- uses: actions/upload-artifact@v4
- with:
- name: ${{ env.ZIP_NAME }}
- path: dist/${{ env.ZIP_NAME }}
- retention-days: 7 # keep for 7 days, should be enough
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
@@ -65,11 +63,38 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
+ - name: Check build loads expected worlds
+ shell: bash
+ run: |
+ cd build/exe*
+ mv Players/Templates/meta.yaml .
+ ls -1 Players/Templates | sort > setup-player-templates.txt
+ rm -R Players/Templates
+ timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
+ ls -1 Players/Templates | sort > generated-player-templates.txt
+ cmp setup-player-templates.txt generated-player-templates.txt \
+ || diff setup-player-templates.txt generated-player-templates.txt
+ mv meta.yaml Players/Templates/
+ - name: Test Generate
+ shell: bash
+ run: |
+ cd build/exe*
+ cp Players/Templates/Clique.yaml Players/
+ timeout 30 ./ArchipelagoGenerate
+ - name: Store 7z
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ env.ZIP_NAME }}
+ path: dist/${{ env.ZIP_NAME }}
+ compression-level: 0 # .7z is incompressible by zip
+ if-no-files-found: error
+ retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup
uses: actions/upload-artifact@v4
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
+ if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004:
@@ -110,7 +135,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
- (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
+ (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
@@ -118,15 +143,36 @@ jobs:
run: |
source venv/bin/activate
python setup.py build_exe --yes
+ - name: Check build loads expected worlds
+ shell: bash
+ run: |
+ cd build/exe*
+ mv Players/Templates/meta.yaml .
+ ls -1 Players/Templates | sort > setup-player-templates.txt
+ rm -R Players/Templates
+ timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
+ ls -1 Players/Templates | sort > generated-player-templates.txt
+ cmp setup-player-templates.txt generated-player-templates.txt \
+ || diff setup-player-templates.txt generated-player-templates.txt
+ mv meta.yaml Players/Templates/
+ - name: Test Generate
+ shell: bash
+ run: |
+ cd build/exe*
+ cp Players/Templates/Clique.yaml Players/
+ timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
+ if-no-files-found: error
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v4
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
+ compression-level: 0 # .gz is incompressible by zip
+ if-no-files-found: error
retention-days: 7
diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml
new file mode 100644
index 000000000000..9492c83c9e53
--- /dev/null
+++ b/.github/workflows/ctest.yml
@@ -0,0 +1,54 @@
+# Run CMake / CTest C++ unit tests
+
+name: ctest
+
+on:
+ push:
+ paths:
+ - '**.cc?'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.hh?'
+ - '**.hpp'
+ - '**.hxx'
+ - '**.CMakeLists'
+ - '.github/workflows/ctest.yml'
+ pull_request:
+ paths:
+ - '**.cc?'
+ - '**.cpp'
+ - '**.cxx'
+ - '**.hh?'
+ - '**.hpp'
+ - '**.hxx'
+ - '**.CMakeLists'
+ - '.github/workflows/ctest.yml'
+
+jobs:
+ ctest:
+ runs-on: ${{ matrix.os }}
+ name: Test C++ ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: ilammy/msvc-dev-cmd@v1
+ if: startsWith(matrix.os,'windows')
+ - uses: Bacondish2023/setup-googletest@v1
+ with:
+ build-type: 'Release'
+ - name: Build tests
+ run: |
+ cd test/cpp
+ mkdir build
+ cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
+ cmake --build build/ --config Release
+ ls
+ - name: Run tests
+ run: |
+ cd test/cpp
+ ctest --test-dir build/ -C Release --output-on-failure
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2d7f1253b760..3f8651d408e7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,7 +69,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
- (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
+ (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
diff --git a/.gitignore b/.gitignore
index 0bba6f17264b..5686f43de380 100644
--- a/.gitignore
+++ b/.gitignore
@@ -178,6 +178,7 @@ dmypy.json
cython_debug/
# Cython intermediates
+_speedups.c
_speedups.cpp
_speedups.html
diff --git a/AdventureClient.py b/AdventureClient.py
index 7bfbd5ef6bd3..206c55df9abd 100644
--- a/AdventureClient.py
+++ b/AdventureClient.py
@@ -80,7 +80,7 @@ def __init__(self, server_address, password):
self.local_item_locations = {}
self.dragon_speed_info = {}
- options = Utils.get_options()
+ options = Utils.get_settings()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
@@ -102,7 +102,7 @@ def _set_message(self, msg: str, msg_id: int):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
- if Utils.get_options()["adventure_options"].get("death_link", False):
+ if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
- auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
- rom_args = Utils.get_options()["adventure_options"].get("rom_args")
+ auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
+ rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
diff --git a/Fill.py b/Fill.py
index d8147b2eac80..4967ff073601 100644
--- a/Fill.py
+++ b/Fill.py
@@ -483,15 +483,15 @@ def mark_for_locking(location: Location):
if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=True,
- on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
+ name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False,
- on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
+ name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False, allow_partial=True,
- on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
+ name="Progression", single_player_placement=multiworld.players == 1)
if progitempool:
for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
diff --git a/Generate.py b/Generate.py
index 0cef081120e6..1fbb9e76a483 100644
--- a/Generate.py
+++ b/Generate.py
@@ -66,13 +66,15 @@ def get_seed_name(random_source) -> str:
def main(args=None):
+ # __name__ == "__main__" check so unittests that already imported worlds don't trip this.
+ if __name__ == "__main__" and "worlds" in sys.modules:
+ raise Exception("Worlds system should not be loaded before logging init.")
+
if not args:
args = mystery_argparse()
seed = get_seed(args.seed)
- # __name__ == "__main__" check so unittests that already imported worlds don't trip this.
- if __name__ == "__main__" and "worlds" in sys.modules:
- raise Exception("Worlds system should not be loaded before logging init.")
+
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed)
seed_name = get_seed_name(random)
diff --git a/WebHost.py b/WebHost.py
index afacd6288ec2..08ef3c430795 100644
--- a/WebHost.py
+++ b/WebHost.py
@@ -58,6 +58,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
+ shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, game)
diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html
index 55a56e32851d..c7a5d4174b64 100644
--- a/WebHostLib/templates/weightedOptions/macros.html
+++ b/WebHostLib/templates/weightedOptions/macros.html
@@ -47,7 +47,7 @@
{% endif %}
-
+
@@ -72,7 +72,7 @@
This option allows custom values only. Please enter your desired values below.
-
+
@@ -89,7 +89,7 @@
Custom values are also allowed for this option. To create one, enter it into the input box below.
-
+
diff --git a/_speedups.pyx b/_speedups.pyx
index b4167ec5aa1e..4b083c2f9aef 100644
--- a/_speedups.pyx
+++ b/_speedups.pyx
@@ -1,5 +1,6 @@
#cython: language_level=3
-#distutils: language = c++
+#distutils: language = c
+#distutils: depends = intset.h
"""
Provides faster implementation of some core parts.
@@ -13,7 +14,6 @@ from cpython cimport PyObject
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
from cymem.cymem cimport Pool
from libc.stdint cimport int64_t, uint32_t
-from libcpp.set cimport set as std_set
from collections import defaultdict
cdef extern from *:
@@ -31,6 +31,27 @@ ctypedef int64_t ap_id_t
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
cdef size_t INVALID_SIZE = (-1) # this is all 0xff... adding 1 results in 0, but it's not negative
+# configure INTSET for player
+cdef extern from *:
+ """
+ #define INTSET_NAME ap_player_set
+ #define INTSET_TYPE uint32_t // has to match ap_player_t
+ """
+
+# create INTSET for player
+cdef extern from "intset.h":
+ """
+ #undef INTSET_NAME
+ #undef INTSET_TYPE
+ """
+ ctypedef struct ap_player_set:
+ pass
+
+ ap_player_set* ap_player_set_new(size_t bucket_count) nogil
+ void ap_player_set_free(ap_player_set* set) nogil
+ bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil
+ bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil
+
cdef struct LocationEntry:
# layout is so that
@@ -185,7 +206,7 @@ cdef class LocationStore:
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
cdef ap_id_t item = seeked_item_id
cdef ap_player_t receiver
- cdef std_set[ap_player_t] receivers
+ cdef ap_player_set* receivers
cdef size_t slot_count = len(slots)
if slot_count == 1:
# specialized implementation for single slot
@@ -197,13 +218,20 @@ cdef class LocationStore:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
elif slot_count:
# generic implementation with lookup in set
- for receiver in slots:
- receivers.insert(receiver)
- with nogil:
- for entry in self.entries[:self.entry_count]:
- if entry.item == item and receivers.count(entry.receiver):
- with gil:
- yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
+ receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB
+ if not receivers:
+ raise MemoryError()
+ try:
+ for receiver in slots:
+ if not ap_player_set_add(receivers, receiver):
+ raise MemoryError()
+ with nogil:
+ for entry in self.entries[:self.entry_count]:
+ if entry.item == item and ap_player_set_contains(receivers, entry.receiver):
+ with gil:
+ yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
+ finally:
+ ap_player_set_free(receivers)
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
cdef ap_player_t receiver = slot
diff --git a/_speedups.pyxbld b/_speedups.pyxbld
index e1fe19b2efc6..974eaed03b6a 100644
--- a/_speedups.pyxbld
+++ b/_speedups.pyxbld
@@ -1,8 +1,10 @@
-# This file is required to get pyximport to work with C++.
-# Switching from std::set to a pure C implementation is still on the table to simplify everything.
+# This file is used when doing pyximport
+import os
def make_ext(modname, pyxfilename):
from distutils.extension import Extension
return Extension(name=modname,
sources=[pyxfilename],
- language='c++')
+ depends=["intset.h"],
+ include_dirs=[os.getcwd()],
+ language="c")
diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS
index 53d9ba4e8f9d..f27a9797ec20 100644
--- a/docs/CODEOWNERS
+++ b/docs/CODEOWNERS
@@ -73,7 +73,7 @@
/worlds/heretic/ @Daivuk
# Hollow Knight
-/worlds/hk/ @BadMagic100 @ThePhar
+/worlds/hk/ @BadMagic100 @qwint
# Hylics 2
/worlds/hylics2/ @TRPG0
diff --git a/inno_setup.iss b/inno_setup.iss
index 9169ab31387d..a2b9f8990b61 100644
--- a/inno_setup.iss
+++ b/inno_setup.iss
@@ -89,6 +89,9 @@ Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
+Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
+Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
+Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
diff --git a/intset.h b/intset.h
new file mode 100644
index 000000000000..fac84fb6f890
--- /dev/null
+++ b/intset.h
@@ -0,0 +1,135 @@
+/* A specialized unordered_set implementation for literals, where bucket_count
+ * is defined at initialization rather than increased automatically.
+ */
+#include
+#include
+#include
+#include
+
+#ifndef INTSET_NAME
+#error "Please #define INTSET_NAME ... before including intset.h"
+#endif
+
+#ifndef INTSET_TYPE
+#error "Please #define INTSET_TYPE ... before including intset.h"
+#endif
+
+/* macros to generate unique names from INTSET_NAME */
+#ifndef INTSET_CONCAT
+#define INTSET_CONCAT_(a, b) a ## b
+#define INTSET_CONCAT(a, b) INTSET_CONCAT_(a, b)
+#define INTSET_FUNC_(a, b) INTSET_CONCAT(a, _ ## b)
+#endif
+
+#define INTSET_FUNC(name) INTSET_FUNC_(INTSET_NAME, name)
+#define INTSET_BUCKET INTSET_CONCAT(INTSET_NAME, Bucket)
+#define INTSET_UNION INTSET_CONCAT(INTSET_NAME, Union)
+
+#if defined(_MSC_VER)
+#pragma warning(push)
+#pragma warning(disable : 4200)
+#endif
+
+
+typedef struct {
+ size_t count;
+ union INTSET_UNION {
+ INTSET_TYPE val;
+ INTSET_TYPE *data;
+ } v;
+} INTSET_BUCKET;
+
+typedef struct {
+ size_t bucket_count;
+ INTSET_BUCKET buckets[];
+} INTSET_NAME;
+
+static INTSET_NAME *INTSET_FUNC(new)(size_t buckets)
+{
+ size_t i, size;
+ INTSET_NAME *set;
+
+ if (buckets < 1)
+ buckets = 1;
+ if ((SIZE_MAX - sizeof(INTSET_NAME)) / sizeof(INTSET_BUCKET) < buckets)
+ return NULL;
+ size = sizeof(INTSET_NAME) + buckets * sizeof(INTSET_BUCKET);
+ set = (INTSET_NAME*)malloc(size);
+ if (!set)
+ return NULL;
+ memset(set, 0, size); /* gcc -fanalyzer does not understand this sets all buckets' count to 0 */
+ for (i = 0; i < buckets; i++) {
+ set->buckets[i].count = 0;
+ }
+ set->bucket_count = buckets;
+ return set;
+}
+
+static void INTSET_FUNC(free)(INTSET_NAME *set)
+{
+ size_t i;
+ if (!set)
+ return;
+ for (i = 0; i < set->bucket_count; i++) {
+ if (set->buckets[i].count > 1)
+ free(set->buckets[i].v.data);
+ }
+ free(set);
+}
+
+static bool INTSET_FUNC(contains)(INTSET_NAME *set, INTSET_TYPE val)
+{
+ size_t i;
+ INTSET_BUCKET* bucket = &set->buckets[(size_t)val % set->bucket_count];
+ if (bucket->count == 1)
+ return bucket->v.val == val;
+ for (i = 0; i < bucket->count; ++i) {
+ if (bucket->v.data[i] == val)
+ return true;
+ }
+ return false;
+}
+
+static bool INTSET_FUNC(add)(INTSET_NAME *set, INTSET_TYPE val)
+{
+ INTSET_BUCKET* bucket;
+
+ if (INTSET_FUNC(contains)(set, val))
+ return true; /* ok */
+
+ bucket = &set->buckets[(size_t)val % set->bucket_count];
+ if (bucket->count == 0) {
+ bucket->v.val = val;
+ bucket->count = 1;
+ } else if (bucket->count == 1) {
+ INTSET_TYPE old = bucket->v.val;
+ bucket->v.data = (INTSET_TYPE*)malloc(2 * sizeof(INTSET_TYPE));
+ if (!bucket->v.data) {
+ bucket->v.val = old;
+ return false; /* error */
+ }
+ bucket->v.data[0] = old;
+ bucket->v.data[1] = val;
+ bucket->count = 2;
+ } else {
+ size_t new_bucket_size;
+ INTSET_TYPE* new_bucket_data;
+
+ new_bucket_size = (bucket->count + 1) * sizeof(INTSET_TYPE);
+ new_bucket_data = (INTSET_TYPE*)realloc(bucket->v.data, new_bucket_size);
+ if (!new_bucket_data)
+ return false; /* error */
+ bucket->v.data = new_bucket_data;
+ bucket->v.data[bucket->count++] = val;
+ }
+ return true; /* success */
+}
+
+
+#if defined(_MSC_VER)
+#pragma warning(pop)
+#endif
+
+#undef INTSET_FUNC
+#undef INTSET_BUCKET
+#undef INTSET_UNION
diff --git a/test/cpp/CMakeLists.txt b/test/cpp/CMakeLists.txt
new file mode 100644
index 000000000000..927b7494dac4
--- /dev/null
+++ b/test/cpp/CMakeLists.txt
@@ -0,0 +1,49 @@
+cmake_minimum_required(VERSION 3.5)
+project(ap-cpp-tests)
+
+enable_testing()
+
+find_package(GTest REQUIRED)
+
+if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
+ add_definitions("/source-charset:utf-8")
+ set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
+ set(CMAKE_CXX_FLAGS_RELEASE "/MT")
+elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
+ # enable static analysis for gcc
+ add_compile_options(-fanalyzer -Werror)
+ # disable stuff that gets triggered by googletest
+ add_compile_options(-Wno-analyzer-malloc-leak)
+ # enable asan for gcc
+ add_compile_options(-fsanitize=address)
+ add_link_options(-fsanitize=address)
+endif ()
+
+add_executable(test_default)
+
+target_include_directories(test_default
+ PRIVATE
+ ${GTEST_INCLUDE_DIRS}
+)
+
+target_link_libraries(test_default
+ ${GTEST_BOTH_LIBRARIES}
+)
+
+add_test(
+ NAME test_default
+ COMMAND test_default
+)
+
+set_property(
+ TEST test_default
+ PROPERTY ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1"
+)
+
+file(GLOB ITEMS *)
+foreach(item ${ITEMS})
+ if(IS_DIRECTORY ${item} AND EXISTS ${item}/CMakeLists.txt)
+ message(${item})
+ add_subdirectory(${item})
+ endif()
+endforeach()
diff --git a/test/cpp/README.md b/test/cpp/README.md
new file mode 100644
index 000000000000..792b9be77e72
--- /dev/null
+++ b/test/cpp/README.md
@@ -0,0 +1,32 @@
+# C++ tests
+
+Test framework for C and C++ code in AP.
+
+## Adding a Test
+
+### GoogleTest
+
+Adding GoogleTests is as simple as creating a directory with
+* one or more `test_*.cpp` files that define tests using
+ [GoogleTest API](https://google.github.io/googletest/)
+* a `CMakeLists.txt` that adds the .cpp files to `test_default` target using
+ [target_sources](https://cmake.org/cmake/help/latest/command/target_sources.html)
+
+### CTest
+
+If either GoogleTest is not suitable for the test or the build flags / sources / libraries are incompatible,
+you can add another CTest to the project using add_target and add_test, similar to how it's done for `test_default`.
+
+## Running Tests
+
+* Install [CMake](https://cmake.org/).
+* Build and/or install GoogleTest and make sure
+ [CMake can find it](https://cmake.org/cmake/help/latest/module/FindGTest.html), or
+ [create a parent `CMakeLists.txt` that fetches GoogleTest](https://google.github.io/googletest/quickstart-cmake.html).
+* Enter the directory with the top-most `CMakeLists.txt` and run
+ ```sh
+ mkdir build
+ cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
+ cmake --build build/ --config Release && \
+ ctest --test-dir build/ -C Release --output-on-failure
+ ```
diff --git a/test/cpp/intset/CMakeLists.txt b/test/cpp/intset/CMakeLists.txt
new file mode 100644
index 000000000000..175e0bd0b9e8
--- /dev/null
+++ b/test/cpp/intset/CMakeLists.txt
@@ -0,0 +1,4 @@
+target_sources(test_default
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
+)
diff --git a/test/cpp/intset/test_intset.cpp b/test/cpp/intset/test_intset.cpp
new file mode 100644
index 000000000000..2f85bea960c4
--- /dev/null
+++ b/test/cpp/intset/test_intset.cpp
@@ -0,0 +1,105 @@
+#include
+#include
+#include
+
+// uint32Set
+#define INTSET_NAME uint32Set
+#define INTSET_TYPE uint32_t
+#include "../../../intset.h"
+#undef INTSET_NAME
+#undef INTSET_TYPE
+
+// int64Set
+#define INTSET_NAME int64Set
+#define INTSET_TYPE int64_t
+#include "../../../intset.h"
+
+
+TEST(IntsetTest, ZeroBuckets)
+{
+ // trying to allocate with zero buckets has to either fail or be functioning
+ uint32Set *set = uint32Set_new(0);
+ if (!set)
+ return; // failed -> OK
+
+ EXPECT_FALSE(uint32Set_contains(set, 1));
+ EXPECT_TRUE(uint32Set_add(set, 1));
+ EXPECT_TRUE(uint32Set_contains(set, 1));
+ uint32Set_free(set);
+}
+
+TEST(IntsetTest, Duplicate)
+{
+ // adding the same number again can't fail
+ uint32Set *set = uint32Set_new(2);
+ ASSERT_TRUE(set);
+ EXPECT_TRUE(uint32Set_add(set, 0));
+ EXPECT_TRUE(uint32Set_add(set, 0));
+ EXPECT_TRUE(uint32Set_contains(set, 0));
+ uint32Set_free(set);
+}
+
+TEST(IntsetTest, SetAllocFailure)
+{
+ // try to allocate 100TB of RAM, should fail and return NULL
+ if (sizeof(size_t) < 8)
+ GTEST_SKIP() << "Alloc error not testable on 32bit";
+ int64Set *set = int64Set_new(6250000000000ULL);
+ EXPECT_FALSE(set);
+ int64Set_free(set);
+}
+
+TEST(IntsetTest, SetAllocOverflow)
+{
+ // try to overflow argument passed to malloc
+ int64Set *set = int64Set_new(std::numeric_limits::max());
+ EXPECT_FALSE(set);
+ int64Set_free(set);
+}
+
+TEST(IntsetTest, NullFree)
+{
+ // free(NULL) should not try to free buckets
+ uint32Set_free(NULL);
+ int64Set_free(NULL);
+}
+
+TEST(IntsetTest, BucketRealloc)
+{
+ // add a couple of values to the same bucket to test growing the bucket
+ uint32Set* set = uint32Set_new(1);
+ ASSERT_TRUE(set);
+ EXPECT_FALSE(uint32Set_contains(set, 0));
+ EXPECT_TRUE(uint32Set_add(set, 0));
+ EXPECT_TRUE(uint32Set_contains(set, 0));
+ for (uint32_t i = 1; i < 32; ++i) {
+ EXPECT_TRUE(uint32Set_add(set, i));
+ EXPECT_TRUE(uint32Set_contains(set, i - 1));
+ EXPECT_TRUE(uint32Set_contains(set, i));
+ EXPECT_FALSE(uint32Set_contains(set, i + 1));
+ }
+ uint32Set_free(set);
+}
+
+TEST(IntSet, Max)
+{
+ constexpr auto n = std::numeric_limits::max();
+ uint32Set *set = uint32Set_new(1);
+ ASSERT_TRUE(set);
+ EXPECT_FALSE(uint32Set_contains(set, n));
+ EXPECT_TRUE(uint32Set_add(set, n));
+ EXPECT_TRUE(uint32Set_contains(set, n));
+ uint32Set_free(set);
+}
+
+TEST(InsetTest, Negative)
+{
+ constexpr auto n = std::numeric_limits::min();
+ static_assert(n < 0, "n not negative");
+ int64Set *set = int64Set_new(3);
+ ASSERT_TRUE(set);
+ EXPECT_FALSE(int64Set_contains(set, n));
+ EXPECT_TRUE(int64Set_add(set, n));
+ EXPECT_TRUE(int64Set_contains(set, n));
+ int64Set_free(set);
+}
diff --git a/test/general/__init__.py b/test/general/__init__.py
index 1d4fc80c3e55..8afd84976540 100644
--- a/test/general/__init__.py
+++ b/test/general/__init__.py
@@ -2,6 +2,7 @@
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
+from worlds import network_data_package
from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
@@ -60,6 +61,10 @@ class TestWorld(World):
hidden = True
+# add our test world to the data package, so we can test it later
+network_data_package["games"][TestWorld.game] = TestWorld.get_data_package_data()
+
+
def generate_test_multiworld(players: int = 1) -> MultiWorld:
"""
Generates a multiworld using a special Test Case World class, and seed of 0.
diff --git a/test/general/test_ids.py b/test/general/test_ids.py
index e4010af394f5..e51a070c1fd7 100644
--- a/test/general/test_ids.py
+++ b/test/general/test_ids.py
@@ -1,6 +1,7 @@
import unittest
from Fill import distribute_items_restrictive
+from worlds import network_data_package
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
@@ -84,3 +85,4 @@ def test_postgen_datapackage(self):
f"{loc_name} is not a valid item name for location_name_to_id")
self.assertIsInstance(loc_id, int,
f"{loc_id} for {loc_name} should be an int")
+ self.assertEqual(datapackage["checksum"], network_data_package["games"][gamename]["checksum"])
diff --git a/test/netutils/test_location_store.py b/test/netutils/test_location_store.py
index a7f117255faa..f3e83989bea4 100644
--- a/test/netutils/test_location_store.py
+++ b/test/netutils/test_location_store.py
@@ -1,4 +1,5 @@
# Tests for _speedups.LocationStore and NetUtils._LocationStore
+import os
import typing
import unittest
import warnings
@@ -7,6 +8,8 @@
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
+ci = bool(os.environ.get("CI")) # always set in GitHub actions
+
sample_data: RawLocations = {
1: {
11: (21, 2, 7),
@@ -24,6 +27,9 @@
3: {
9: (99, 4, 0),
},
+ 5: {
+ 9: (99, 5, 0),
+ }
}
empty_state: State = {
@@ -45,14 +51,14 @@ class TestLocationStore(unittest.TestCase):
store: typing.Union[LocationStore, _LocationStore]
def test_len(self) -> None:
- self.assertEqual(len(self.store), 4)
+ self.assertEqual(len(self.store), 5)
self.assertEqual(len(self.store[1]), 3)
def test_key_error(self) -> None:
with self.assertRaises(KeyError):
_ = self.store[0]
with self.assertRaises(KeyError):
- _ = self.store[5]
+ _ = self.store[6]
locations = self.store[1] # no Exception
with self.assertRaises(KeyError):
_ = locations[7]
@@ -71,7 +77,7 @@ def test_get(self) -> None:
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
def test_iter(self) -> None:
- self.assertEqual(sorted(self.store), [1, 2, 3, 4])
+ self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
self.assertEqual(len(self.store), len(sample_data))
self.assertEqual(list(self.store[1]), [11, 12, 13])
self.assertEqual(len(self.store[1]), len(sample_data[1]))
@@ -85,13 +91,26 @@ def test_items(self) -> None:
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
def test_find_item(self) -> None:
+ # empty player set
self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
+ # no such player, single
+ self.assertEqual(sorted(self.store.find_item({6}, 99)), [])
+ # no such player, set
+ self.assertEqual(sorted(self.store.find_item({7, 8, 9}, 99)), [])
+ # no such item
self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
- self.assertEqual(sorted(self.store.find_item({5}, 99)), [])
+ # valid matches
self.assertEqual(sorted(self.store.find_item({3}, 99)),
[(4, 9, 99, 3, 0)])
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
+ self.assertEqual(sorted(self.store.find_item({2, 3, 4}, 99)),
+ [(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
+ # test hash collision in set
+ self.assertEqual(sorted(self.store.find_item({3, 5}, 99)),
+ [(4, 9, 99, 3, 0), (5, 9, 99, 5, 0)])
+ self.assertEqual(sorted(self.store.find_item(set(range(2048)), 13)),
+ [(1, 13, 13, 1, 0)])
def test_get_for_player(self) -> None:
self.assertEqual(self.store.get_for_player(3), {4: {9}})
@@ -196,18 +215,20 @@ def setUp(self) -> None:
super().setUp()
-@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
+@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
class TestSpeedupsLocationStore(Base.TestLocationStore):
"""Run base method tests for cython implementation."""
def setUp(self) -> None:
+ self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.store = LocationStore(sample_data)
super().setUp()
-@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
+@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
"""Run base constructor tests and tests the additional constraints for cython implementation."""
def setUp(self) -> None:
+ self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.type = LocationStore
super().setUp()
diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py
index 6e17f023f6fb..bed375cf080a 100644
--- a/worlds/AutoWorld.py
+++ b/worlds/AutoWorld.py
@@ -123,8 +123,8 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Web
assert group.options, "A custom defined Option Group must contain at least one Option."
# catch incorrectly titled versions of the prebuilt groups so they don't create extra groups
title_name = group.name.title()
- if title_name in prebuilt_options:
- group.name = title_name
+ assert title_name not in prebuilt_options or title_name == group.name, \
+ f"Prebuilt group name \"{group.name}\" must be \"{title_name}\""
if group.name == "Item & Location Options":
assert not any(option in item_and_loc_options for option in group.options), \
diff --git a/worlds/_sc2common/bot/sc2process.py b/worlds/_sc2common/bot/sc2process.py
index e36632165979..f74ed9c18f9f 100644
--- a/worlds/_sc2common/bot/sc2process.py
+++ b/worlds/_sc2common/bot/sc2process.py
@@ -28,6 +28,11 @@ def add(cls, value):
logger.debug("kill_switch: Add switch")
cls._to_kill.append(value)
+ @classmethod
+ def kill(cls, value):
+ logger.info(f"kill_switch: Process cleanup for 1 process")
+ value._clean(verbose=False)
+
@classmethod
def kill_all(cls):
logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes")
@@ -116,7 +121,7 @@ def signal_handler(*_args):
async def __aexit__(self, *args):
logger.exception("async exit")
await self._close_connection()
- kill_switch.kill_all()
+ kill_switch.kill(self)
signal.signal(signal.SIGINT, signal.SIG_DFL)
@property
diff --git a/worlds/adventure/Options.py b/worlds/adventure/Options.py
index 9e0cc9d686b8..e6a8e4c20200 100644
--- a/worlds/adventure/Options.py
+++ b/worlds/adventure/Options.py
@@ -2,7 +2,8 @@
from typing import Dict
-from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle
+from dataclasses import dataclass
+from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
class FreeincarnateMax(Range):
@@ -223,22 +224,22 @@ class StartCastle(Choice):
option_white = 2
default = option_yellow
+@dataclass
+class AdventureOptions(PerGameCommonOptions):
+ dragon_slay_check: DragonSlayCheck
+ death_link: DeathLink
+ bat_logic: BatLogic
+ freeincarnate_max: FreeincarnateMax
+ dragon_rando_type: DragonRandoType
+ connector_multi_slot: ConnectorMultiSlot
+ yorgle_speed: YorgleStartingSpeed
+ yorgle_min_speed: YorgleMinimumSpeed
+ grundle_speed: GrundleStartingSpeed
+ grundle_min_speed: GrundleMinimumSpeed
+ rhindle_speed: RhindleStartingSpeed
+ rhindle_min_speed: RhindleMinimumSpeed
+ difficulty_switch_a: DifficultySwitchA
+ difficulty_switch_b: DifficultySwitchB
+ start_castle: StartCastle
+
-adventure_option_definitions: Dict[str, type(Option)] = {
- "dragon_slay_check": DragonSlayCheck,
- "death_link": DeathLink,
- "bat_logic": BatLogic,
- "freeincarnate_max": FreeincarnateMax,
- "dragon_rando_type": DragonRandoType,
- "connector_multi_slot": ConnectorMultiSlot,
- "yorgle_speed": YorgleStartingSpeed,
- "yorgle_min_speed": YorgleMinimumSpeed,
- "grundle_speed": GrundleStartingSpeed,
- "grundle_min_speed": GrundleMinimumSpeed,
- "rhindle_speed": RhindleStartingSpeed,
- "rhindle_min_speed": RhindleMinimumSpeed,
- "difficulty_switch_a": DifficultySwitchA,
- "difficulty_switch_b": DifficultySwitchB,
- "start_castle": StartCastle,
-
-}
diff --git a/worlds/adventure/Regions.py b/worlds/adventure/Regions.py
index 00617b2f7164..e72806ca454f 100644
--- a/worlds/adventure/Regions.py
+++ b/worlds/adventure/Regions.py
@@ -1,4 +1,5 @@
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
+from Options import PerGameCommonOptions
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
@@ -24,7 +25,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
connect(world, player, target, source, rule, True)
-def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
+def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
menu = Region("Menu", player, multiworld)
@@ -74,7 +75,7 @@ def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> Non
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
multiworld.regions.append(credits_room_far_side)
- dragon_slay_check = multiworld.dragon_slay_check[player].value
+ dragon_slay_check = options.dragon_slay_check.value
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
for name, location_data in location_table.items():
diff --git a/worlds/adventure/Rules.py b/worlds/adventure/Rules.py
index 6f4b53faa11b..930295301288 100644
--- a/worlds/adventure/Rules.py
+++ b/worlds/adventure/Rules.py
@@ -6,7 +6,7 @@
def set_rules(self) -> None:
world = self.multiworld
- use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic
+ use_bat_logic = self.options.bat_logic.value == BatLogic.option_use_logic
set_rule(world.get_entrance("YellowCastlePort", self.player),
lambda state: state.has("Yellow Key", self.player))
@@ -28,7 +28,7 @@ def set_rules(self) -> None:
lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player))
- dragon_slay_check = world.dragon_slay_check[self.player].value
+ dragon_slay_check = self.options.dragon_slay_check.value
if dragon_slay_check:
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
set_rule(world.get_location("Slay Yorgle", self.player),
diff --git a/worlds/adventure/__init__.py b/worlds/adventure/__init__.py
index 1c2583b3ed6e..ed5ebbd3dc56 100644
--- a/worlds/adventure/__init__.py
+++ b/worlds/adventure/__init__.py
@@ -15,7 +15,8 @@
from worlds.AutoWorld import WebWorld, World
from Fill import fill_restrictive
from worlds.generic.Rules import add_rule, set_rule
-from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
+from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
+ AdventureOptions
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
AdventureAutoCollectLocation
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
@@ -109,7 +110,7 @@ class AdventureWorld(World):
game: ClassVar[str] = "Adventure"
web: ClassVar[WebWorld] = AdventureWeb()
- option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions
+ options_dataclass = AdventureOptions
settings: ClassVar[AdventureSettings]
item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
@@ -149,18 +150,18 @@ def generate_early(self) -> None:
bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
self.rom_name.extend([0] * (21 - len(self.rom_name)))
- self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value
- self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value
- self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value
- self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value
- self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value
- self.grundle_speed = self.multiworld.grundle_speed[self.player].value
- self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value
- self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value
- self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value
- self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value
- self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value
- self.start_castle = self.multiworld.start_castle[self.player].value
+ self.dragon_rando_type = self.options.dragon_rando_type.value
+ self.dragon_slay_check = self.options.dragon_slay_check.value
+ self.connector_multi_slot = self.options.connector_multi_slot.value
+ self.yorgle_speed = self.options.yorgle_speed.value
+ self.yorgle_min_speed = self.options.yorgle_min_speed.value
+ self.grundle_speed = self.options.grundle_speed.value
+ self.grundle_min_speed = self.options.grundle_min_speed.value
+ self.rhindle_speed = self.options.rhindle_speed.value
+ self.rhindle_min_speed = self.options.rhindle_min_speed.value
+ self.difficulty_switch_a = self.options.difficulty_switch_a.value
+ self.difficulty_switch_b = self.options.difficulty_switch_b.value
+ self.start_castle = self.options.start_castle.value
self.created_items = 0
if self.dragon_slay_check == 0:
@@ -227,7 +228,7 @@ def create_items(self) -> None:
extra_filler_count = num_locations - self.created_items
# traps would probably go here, if enabled
- freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value
+ freeincarnate_max = self.options.freeincarnate_max.value
actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
self.created_items += actual_freeincarnates
@@ -247,7 +248,7 @@ def create_dragon_slow_items(self, min_speed: int, speed: int, item_name: str, m
self.created_items += 1
def create_regions(self) -> None:
- create_regions(self.multiworld, self.player, self.dragon_rooms)
+ create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
set_rules = set_rules
@@ -354,7 +355,7 @@ def generate_output(self, output_directory: str) -> None:
auto_collect_locations: [AdventureAutoCollectLocation] = []
local_item_to_location: {int, int} = {}
bat_no_touch_locs: [LocationData] = []
- bat_logic: int = self.multiworld.bat_logic[self.player].value
+ bat_logic: int = self.options.bat_logic.value
try:
rom_deltas: { int, int } = {}
self.place_dragons(rom_deltas)
@@ -421,7 +422,7 @@ def generate_output(self, output_directory: str) -> None:
item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
rom_deltas[item_position_data_start] = 0xff
- if self.multiworld.connector_multi_slot[self.player].value:
+ if self.options.connector_multi_slot.value:
rom_deltas[connector_port_offset] = (self.player & 0xff)
else:
rom_deltas[connector_port_offset] = 0
diff --git a/worlds/aquaria/Locations.py b/worlds/aquaria/Locations.py
index 7360efde065e..33d165db411a 100644
--- a/worlds/aquaria/Locations.py
+++ b/worlds/aquaria/Locations.py
@@ -185,7 +185,7 @@ class AquariaLocations:
"Mithalas City, second bulb at the end of the top path": 698040,
"Mithalas City, bulb in the top path": 698036,
"Mithalas City, Mithalas Pot": 698174,
- "Mithalas City, urn in the Cathedral flower tube entrance": 698128,
+ "Mithalas City, urn in the Castle flower tube entrance": 698128,
}
locations_mithalas_city_fishpass = {
@@ -246,7 +246,7 @@ class AquariaLocations:
"Kelp Forest top left area, bulb in the bottom left clearing": 698044,
"Kelp Forest top left area, bulb in the path down from the top left clearing": 698045,
"Kelp Forest top left area, bulb in the top left clearing": 698046,
- "Kelp Forest top left, Jelly Egg": 698185,
+ "Kelp Forest top left area, Jelly Egg": 698185,
}
locations_forest_tl_fp = {
@@ -332,7 +332,7 @@ class AquariaLocations:
}
locations_veil_tr_l = {
- "The Veil top right area, bulb in the top of the waterfall": 698080,
+ "The Veil top right area, bulb at the top of the waterfall": 698080,
"The Veil top right area, Transturtle": 698210,
}
diff --git a/worlds/aquaria/Regions.py b/worlds/aquaria/Regions.py
index f2f85749f3fb..28120259254c 100755
--- a/worlds/aquaria/Regions.py
+++ b/worlds/aquaria/Regions.py
@@ -771,6 +771,7 @@ def __connect_sunken_city_regions(self) -> None:
self.__connect_regions("Sunken City left area", "Sunken City boss area",
self.sunken_city_l, self.sunken_city_boss,
lambda state: _has_beast_form(state, self.player) and
+ _has_sun_form(state, self.player) and
_has_energy_form(state, self.player) and
_has_bind_song(state, self.player))
@@ -983,7 +984,7 @@ def __adjusting_urns_rules(self) -> None:
lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas City, third urn in the city reserve", self.player),
lambda state: _has_damaging_item(state, self.player))
- add_rule(self.multiworld.get_location("Mithalas City, urn in the Cathedral flower tube entrance", self.player),
+ add_rule(self.multiworld.get_location("Mithalas City, urn in the Castle flower tube entrance", self.player),
lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas City Castle, urn in the bedroom", self.player),
lambda state: _has_damaging_item(state, self.player))
@@ -1023,7 +1024,7 @@ def __adjusting_soup_rules(self) -> None:
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("Sun Worm path, second cliff bulb", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
- add_rule(self.multiworld.get_location("The Veil top right area, bulb in the top of the waterfall", self.player),
+ add_rule(self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
def __adjusting_under_rock_location(self) -> None:
@@ -1175,7 +1176,7 @@ def __no_progression_hard_or_hidden_location(self) -> None:
self.multiworld.get_location("Sun Worm path, second cliff bulb",
self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression
- self.multiworld.get_location("The Veil top right area, bulb in the top of the waterfall",
+ self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall",
self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Bubble Cave, bulb in the left cave wall",
diff --git a/worlds/aquaria/__init__.py b/worlds/aquaria/__init__.py
index 3c0cc3bdedca..ce46aeea75aa 100644
--- a/worlds/aquaria/__init__.py
+++ b/worlds/aquaria/__init__.py
@@ -167,14 +167,10 @@ def create_items(self) -> None:
self.__pre_fill_item("Transturtle Simon Says", "Arnassi Ruins, Transturtle", precollected)
self.__pre_fill_item("Transturtle Arnassi Ruins", "Simon Says area, Transturtle", precollected)
for name, data in item_table.items():
- if name in precollected:
- precollected.remove(name)
- self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
- else:
- if name not in self.exclude:
- for i in range(data.count):
- item = self.create_item(name)
- self.multiworld.itempool.append(item)
+ if name not in self.exclude:
+ for i in range(data.count):
+ item = self.create_item(name)
+ self.multiworld.itempool.append(item)
def set_rules(self) -> None:
"""
diff --git a/worlds/aquaria/test/__init__.py b/worlds/aquaria/test/__init__.py
index 198ccb0f628b..5c63c9bb2968 100644
--- a/worlds/aquaria/test/__init__.py
+++ b/worlds/aquaria/test/__init__.py
@@ -56,7 +56,7 @@
"Mithalas City, second bulb at the end of the top path",
"Mithalas City, bulb in the top path",
"Mithalas City, Mithalas Pot",
- "Mithalas City, urn in the Cathedral flower tube entrance",
+ "Mithalas City, urn in the Castle flower tube entrance",
"Mithalas City, Doll",
"Mithalas City, urn inside a home fish pass",
"Mithalas City Castle, bulb in the flesh hole",
@@ -93,7 +93,7 @@
"Kelp Forest top left area, bulb in the bottom left clearing",
"Kelp Forest top left area, bulb in the path down from the top left clearing",
"Kelp Forest top left area, bulb in the top left clearing",
- "Kelp Forest top left, Jelly Egg",
+ "Kelp Forest top left area, Jelly Egg",
"Kelp Forest top left area, bulb close to the Verse Egg",
"Kelp Forest top left area, Verse Egg",
"Kelp Forest top right area, bulb under the rock in the right path",
@@ -125,7 +125,7 @@
"Turtle cave, Urchin Costume",
"The Veil top right area, bulb in the middle of the wall jump cliff",
"The Veil top right area, Golden Starfish",
- "The Veil top right area, bulb in the top of the waterfall",
+ "The Veil top right area, bulb at the top of the waterfall",
"The Veil top right area, Transturtle",
"The Veil bottom area, bulb in the left path",
"The Veil bottom area, bulb in the spirit path",
diff --git a/worlds/aquaria/test/test_beast_form_access.py b/worlds/aquaria/test/test_beast_form_access.py
index c25070d470b5..4bb4d5656c01 100644
--- a/worlds/aquaria/test/test_beast_form_access.py
+++ b/worlds/aquaria/test/test_beast_form_access.py
@@ -20,14 +20,14 @@ def test_beast_form_location(self) -> None:
"Mithalas City, second bulb at the end of the top path",
"Mithalas City, bulb in the top path",
"Mithalas City, Mithalas Pot",
- "Mithalas City, urn in the Cathedral flower tube entrance",
+ "Mithalas City, urn in the Castle flower tube entrance",
"Mermog cave, Piranha Egg",
"Mithalas Cathedral, Mithalan Dress",
"Turtle cave, bulb in Bubble Cliff",
"Turtle cave, Urchin Costume",
"Sun Worm path, first cliff bulb",
"Sun Worm path, second cliff bulb",
- "The Veil top right area, bulb in the top of the waterfall",
+ "The Veil top right area, bulb at the top of the waterfall",
"Bubble Cave, bulb in the left cave wall",
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg",
diff --git a/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py b/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py
index 817b9547a892..b0d2b0d880fa 100644
--- a/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py
+++ b/worlds/aquaria/test/test_no_progression_hard_hidden_locations.py
@@ -30,7 +30,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
"Final Boss area, bulb in the boss third form room",
"Sun Worm path, first cliff bulb",
"Sun Worm path, second cliff bulb",
- "The Veil top right area, bulb in the top of the waterfall",
+ "The Veil top right area, bulb at the top of the waterfall",
"Bubble Cave, bulb in the left cave wall",
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg",
diff --git a/worlds/aquaria/test/test_progression_hard_hidden_locations.py b/worlds/aquaria/test/test_progression_hard_hidden_locations.py
index 2b7c8ddac93a..390fc40b295d 100644
--- a/worlds/aquaria/test/test_progression_hard_hidden_locations.py
+++ b/worlds/aquaria/test/test_progression_hard_hidden_locations.py
@@ -30,7 +30,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
"Final Boss area, bulb in the boss third form room",
"Sun Worm path, first cliff bulb",
"Sun Worm path, second cliff bulb",
- "The Veil top right area, bulb in the top of the waterfall",
+ "The Veil top right area, bulb at the top of the waterfall",
"Bubble Cave, bulb in the left cave wall",
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg",
diff --git a/worlds/aquaria/test/test_sun_form_access.py b/worlds/aquaria/test/test_sun_form_access.py
index dfd732ec910c..cbe8c08a52a7 100644
--- a/worlds/aquaria/test/test_sun_form_access.py
+++ b/worlds/aquaria/test/test_sun_form_access.py
@@ -18,6 +18,9 @@ def test_sun_form_location(self) -> None:
"Abyss right area, bulb behind the rock in the whale room",
"Octopus Cave, Dumbo Egg",
"Beating Octopus Prime",
+ "Sunken City, bulb on top of the boss area",
+ "Beating the Golem",
+ "Sunken City cleared",
"Final Boss area, bulb in the boss third form room",
"Objective complete"
]
diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py
index 73adf4ebdf0a..1f76dba4894a 100644
--- a/worlds/messenger/options.py
+++ b/worlds/messenger/options.py
@@ -5,7 +5,7 @@
from Options import Accessibility, Choice, DeathLinkMixin, DefaultOnToggle, OptionDict, PerGameCommonOptions, \
PlandoConnections, Range, StartInventoryPool, Toggle, Visibility
-from worlds.messenger.portals import CHECKPOINTS, PORTALS, SHOP_POINTS
+from .portals import CHECKPOINTS, PORTALS, SHOP_POINTS
class MessengerAccessibility(Accessibility):
diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py
index a9eacbbcf82c..ab3a4819fc48 100644
--- a/worlds/musedash/__init__.py
+++ b/worlds/musedash/__init__.py
@@ -249,9 +249,7 @@ def create_items(self) -> None:
def create_regions(self) -> None:
menu_region = Region("Menu", self.player, self.multiworld)
- song_select_region = Region("Song Select", self.player, self.multiworld)
- self.multiworld.regions += [menu_region, song_select_region]
- menu_region.connect(song_select_region)
+ self.multiworld.regions += [menu_region]
# Make a collection of all songs available for this rando.
# 1. All starting songs
@@ -265,18 +263,16 @@ def create_regions(self) -> None:
self.random.shuffle(included_song_copy)
all_selected_locations.extend(included_song_copy)
- # Make a region per song/album, then adds 1-2 item locations to them
+ # Adds 2 item locations per song/album to the menu region.
for i in range(0, len(all_selected_locations)):
name = all_selected_locations[i]
- region = Region(name, self.player, self.multiworld)
- self.multiworld.regions.append(region)
- song_select_region.connect(region, name, lambda state, place=name: state.has(place, self.player))
-
- # Muse Dash requires 2 locations per song to be *interesting*. Balanced out by filler.
- region.add_locations({
- name + "-0": self.md_collection.song_locations[name + "-0"],
- name + "-1": self.md_collection.song_locations[name + "-1"]
- }, MuseDashLocation)
+ loc1 = MuseDashLocation(self.player, name + "-0", self.md_collection.song_locations[name + "-0"], menu_region)
+ loc1.access_rule = lambda state, place=name: state.has(place, self.player)
+ menu_region.locations.append(loc1)
+
+ loc2 = MuseDashLocation(self.player, name + "-1", self.md_collection.song_locations[name + "-1"], menu_region)
+ loc2.access_rule = lambda state, place=name: state.has(place, self.player)
+ menu_region.locations.append(loc2)
def set_rules(self) -> None:
self.multiworld.completion_condition[self.player] = lambda state: \
diff --git a/worlds/sa2b/docs/setup_en.md b/worlds/sa2b/docs/setup_en.md
index 354ef4bbe986..f32001a67827 100644
--- a/worlds/sa2b/docs/setup_en.md
+++ b/worlds/sa2b/docs/setup_en.md
@@ -48,7 +48,7 @@
7. Install protontricks, on the Steam Deck this can be done via the Discover store, on other distros instructions vary, [see its github page](https://github.com/Matoking/protontricks).
-8. Download the [.NET 7 Desktop Runtime for x64 Windows](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-7.0.17-windows-x64-installer}. If this link does not work, the download can be found on [this page](https://dotnet.microsoft.com/en-us/download/dotnet/7.0).
+8. Download the [.NET 7 Desktop Runtime for x64 Windows](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-desktop-7.0.17-windows-x64-installer). If this link does not work, the download can be found on [this page](https://dotnet.microsoft.com/en-us/download/dotnet/7.0).
9. Right click the .NET 7 Desktop Runtime exe, and assuming protontricks was installed correctly, the option to "Open with Protontricks Launcher" should be available. Click that, and in the popup window that opens, select SAModManager.exe. Follow the prompts after this to install the .NET 7 Desktop Runtime for SAModManager. Once it is done, you should be able to successfully launch SAModManager to steam.
diff --git a/worlds/shorthike/Rules.py b/worlds/shorthike/Rules.py
index 4a71ebd3c80a..33741c6d80c6 100644
--- a/worlds/shorthike/Rules.py
+++ b/worlds/shorthike/Rules.py
@@ -1,5 +1,6 @@
from worlds.generic.Rules import forbid_items_for_player, add_rule
-from worlds.shorthike.Options import Goal, GoldenFeatherProgression, MinShopCheckLogic, ShopCheckLogic
+from .Options import Goal, GoldenFeatherProgression, MinShopCheckLogic, ShopCheckLogic
+
def create_rules(self, location_table):
multiworld = self.multiworld
diff --git a/worlds/timespinner/Regions.py b/worlds/timespinner/Regions.py
index 4f53f75eff7a..757a41c38821 100644
--- a/worlds/timespinner/Regions.py
+++ b/worlds/timespinner/Regions.py
@@ -70,7 +70,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, precalculated_w
logic = TimespinnerLogic(world, player, precalculated_weights)
connect(world, player, 'Lake desolation', 'Lower lake desolation', lambda state: flooded.flood_lake_desolation or logic.has_timestop(state) or state.has('Talaria Attachment', player))
- connect(world, player, 'Lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player))
+ connect(world, player, 'Lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player), "Upper Lake Serene")
connect(world, player, 'Lake desolation', 'Skeleton Shaft', lambda state: flooded.flood_lake_desolation or logic.has_doublejump(state))
connect(world, player, 'Lake desolation', 'Space time continuum', logic.has_teleport)
connect(world, player, 'Upper lake desolation', 'Lake desolation')
@@ -80,7 +80,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, precalculated_w
connect(world, player, 'Eastern lake desolation', 'Space time continuum', logic.has_teleport)
connect(world, player, 'Eastern lake desolation', 'Library')
connect(world, player, 'Eastern lake desolation', 'Lower lake desolation')
- connect(world, player, 'Eastern lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player))
+ connect(world, player, 'Eastern lake desolation', 'Upper lake desolation', lambda state: logic.has_fire(state) and state.can_reach('Upper Lake Serene', 'Region', player), "Upper Lake Serene")
connect(world, player, 'Library', 'Eastern lake desolation')
connect(world, player, 'Library', 'Library top', lambda state: logic.has_doublejump(state) or state.has('Talaria Attachment', player))
connect(world, player, 'Library', 'Varndagroth tower left', logic.has_keycard_D)
@@ -185,7 +185,7 @@ def create_regions_and_locations(world: MultiWorld, player: int, precalculated_w
if is_option_enabled(world, player, "GyreArchives"):
connect(world, player, 'The lab (upper)', 'Ravenlord\'s Lair', lambda state: state.has('Merchant Crow', player))
connect(world, player, 'Ravenlord\'s Lair', 'The lab (upper)')
- connect(world, player, 'Library top', 'Ifrit\'s Lair', lambda state: state.has('Kobo', player) and state.can_reach('Refugee Camp', 'Region', player))
+ connect(world, player, 'Library top', 'Ifrit\'s Lair', lambda state: state.has('Kobo', player) and state.can_reach('Refugee Camp', 'Region', player), "Refugee Camp")
connect(world, player, 'Ifrit\'s Lair', 'Library top')
@@ -242,11 +242,19 @@ def connectStartingRegion(world: MultiWorld, player: int):
def connect(world: MultiWorld, player: int, source: str, target: str,
- rule: Optional[Callable[[CollectionState], bool]] = None):
+ rule: Optional[Callable[[CollectionState], bool]] = None,
+ indirect: str = ""):
sourceRegion = world.get_region(source, player)
targetRegion = world.get_region(target, player)
- sourceRegion.connect(targetRegion, rule=rule)
+ entrance = sourceRegion.connect(targetRegion, rule=rule)
+
+ if indirect:
+ indirectRegion = world.get_region(indirect, player)
+ if indirectRegion in world.indirect_connections:
+ world.indirect_connections[indirectRegion].add(entrance)
+ else:
+ world.indirect_connections[indirectRegion] = {entrance}
def split_location_datas_per_region(locations: List[LocationData]) -> Dict[str, List[LocationData]]:
diff --git a/worlds/yugioh06/client_bh.py b/worlds/yugioh06/client_bh.py
index 910eba7c6a88..ecbe48110a6c 100644
--- a/worlds/yugioh06/client_bh.py
+++ b/worlds/yugioh06/client_bh.py
@@ -5,7 +5,7 @@
import worlds._bizhawk as bizhawk
from worlds._bizhawk.client import BizHawkClient
-from worlds.yugioh06 import item_to_index
+from . import item_to_index
if TYPE_CHECKING:
from worlds._bizhawk.context import BizHawkClientContext
diff --git a/worlds/yugioh06/opponents.py b/worlds/yugioh06/opponents.py
index 1746b5652962..68d7c2880f03 100644
--- a/worlds/yugioh06/opponents.py
+++ b/worlds/yugioh06/opponents.py
@@ -3,8 +3,8 @@
from BaseClasses import MultiWorld
from worlds.generic.Rules import CollectionRule
-from worlds.yugioh06 import item_to_index, tier_1_opponents, yugioh06_difficulty
-from worlds.yugioh06.locations import special
+from . import item_to_index, tier_1_opponents, yugioh06_difficulty
+from .locations import special
class OpponentData(NamedTuple):