From 1f6c99635e7f7e55c49a1c3ab41d2444716f8f98 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:25:11 -0500 Subject: [PATCH 01/34] FF1: fix client breaking other NES games (#5293) --- worlds/ff1/Client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/worlds/ff1/Client.py b/worlds/ff1/Client.py index f7315f69f0..a4279afd3a 100644 --- a/worlds/ff1/Client.py +++ b/worlds/ff1/Client.py @@ -89,11 +89,15 @@ class FF1Client(BizHawkClient): async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: try: + if (await bizhawk.get_memory_size(ctx.bizhawk_ctx, self.rom)) < rom_name_location + 0x0D: + return False # ROM is not large enough to be a Final Fantasy 1 ROM # Check ROM name/patch version rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(rom_name_location, 0x0D, self.rom)]))[0]) rom_name = rom_name.decode("ascii") if rom_name != "FINAL FANTASY": return False # Not a Final Fantasy 1 ROM + except UnicodeDecodeError: + return False # rom_name returned invalid text except bizhawk.RequestFailedError: return False # Not able to get a response, say no for now From 4633f129729853fcf77aa075ed6296fddacb41e4 Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Thu, 7 Aug 2025 14:14:09 -0400 Subject: [PATCH 02/34] Docs: Use / instead of . for the reference to lttp's options.py (#5300) * Update options api.md * o -> O --- docs/options api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/options api.md b/docs/options api.md index c9b7c422fe..193d395342 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -344,7 +344,7 @@ names, and `def can_place_boss`, which passes a boss and location, allowing you your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False by default, and will reject duplicate boss names from the user. For an example of using this class, refer to -`worlds.alttp.options.py` +`worlds/alttp/Options.py` ### OptionDict This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the From 17ccfdc266d382ec38b7f1811d8ea430367cdc3b Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:07:36 -0400 Subject: [PATCH 03/34] DS3: Don't Create Disabled Locations (#5292) --- worlds/dark_souls_3/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/worlds/dark_souls_3/__init__.py b/worlds/dark_souls_3/__init__.py index 6584ccec87..a1ad2f6a70 100644 --- a/worlds/dark_souls_3/__init__.py +++ b/worlds/dark_souls_3/__init__.py @@ -267,6 +267,10 @@ class DarkSouls3World(World): # Don't allow missable duplicates of progression items to be expected progression. if location.name in self.missable_dupe_prog_locs: continue + # Don't create DLC and NGP locations if those are disabled + if location.dlc and not self.options.enable_dlc: continue + if location.ngp and not self.options.enable_ngp: continue + # Replace non-randomized items with events that give the default item event_item = ( self.create_item(location.default_item_name) if location.default_item_name From ecb22642af291e05bdc6ae729bb14d4d1ae83792 Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:24:19 -0600 Subject: [PATCH 04/34] Tests: Handle optional args for `get_all_state` patch (#5297) * Make `use_cache` optional * Pass all kwargs --- test/general/test_entrances.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/general/test_entrances.py b/test/general/test_entrances.py index 88362c8fa6..79025534ac 100644 --- a/test/general/test_entrances.py +++ b/test/general/test_entrances.py @@ -48,13 +48,14 @@ class TestBase(unittest.TestCase): original_get_all_state = multiworld.get_all_state - def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False): + def patched_get_all_state(use_cache: bool | None = None, allow_partial_entrances: bool = False, + **kwargs): self.assertTrue(allow_partial_entrances, ( "Before the connect_entrances step finishes, other worlds might still have partial entrances. " "As such, any call to get_all_state must use allow_partial_entrances = True." )) - return original_get_all_state(use_cache, allow_partial_entrances) + return original_get_all_state(use_cache, allow_partial_entrances, **kwargs) multiworld.get_all_state = patched_get_all_state From 9bd535752e5bcde56ac12515217216c18cab57aa Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Sun, 10 Aug 2025 16:03:12 +0100 Subject: [PATCH 05/34] Core: Sort Unreachable Locations Written to the Spoiler (#5269) --- BaseClasses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BaseClasses.py b/BaseClasses.py index 93264d909c..ca717b60f2 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1899,7 +1899,8 @@ class Spoiler: if self.unreachables: outfile.write('\n\nUnreachable Progression Items:\n\n') outfile.write( - '\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables])) + '\n'.join(['%s: %s' % (unreachable.item, unreachable) + for unreachable in sorted(self.unreachables)])) if self.paths: outfile.write('\n\nPaths:\n\n') From c34c00baa433cfb8cb01fa8308b73ecb9dd1cec9 Mon Sep 17 00:00:00 2001 From: Adrian Priestley <47989725+a-priestley@users.noreply.github.com> Date: Sun, 10 Aug 2025 12:39:31 -0230 Subject: [PATCH 06/34] fix(deps): Lock setuptools version to <81 (#5284) - Update Dockerfile to specify "setuptools<81" - Modify ModuleUpdate.py to install setuptools with version constraint --- Dockerfile | 2 +- ModuleUpdate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 46393aab9e..9e3c5f0d71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ COPY requirements.txt WebHostLib/requirements.txt RUN pip install --no-cache-dir -r \ WebHostLib/requirements.txt \ - setuptools + "setuptools<81" COPY _speedups.pyx . COPY intset.h . diff --git a/ModuleUpdate.py b/ModuleUpdate.py index e6ac570e58..2e58c4f7f9 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -78,7 +78,7 @@ def install_pkg_resources(yes=False): check_pip() if not yes: confirm("pkg_resources not found, press enter to install it") - subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"]) + subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools<81"]) def update(yes: bool = False, force: bool = False) -> None: From cdde38fdc9e5f1440249ab8da2cdf8a7edf44ff1 Mon Sep 17 00:00:00 2001 From: qwint Date: Sun, 10 Aug 2025 10:23:39 -0500 Subject: [PATCH 07/34] Settings: warn for broken worlds instead of crashing (#4438) note: i swear the issue was an importerror but i could only get attributeerrors on the getattr() call, maybe we want to check for both? --- settings.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/settings.py b/settings.py index ef1ea9adf7..48bc57f044 100644 --- a/settings.py +++ b/settings.py @@ -754,7 +754,12 @@ class Settings(Group): return super().__getattribute__(key) # directly import world and grab settings class world_mod, world_cls_name = _world_settings_name_cache[key].rsplit(".", 1) - world = cast(type, getattr(__import__(world_mod, fromlist=[world_cls_name]), world_cls_name)) + try: + world = cast(type, getattr(__import__(world_mod, fromlist=[world_cls_name]), world_cls_name)) + except AttributeError: + import warnings + warnings.warn(f"World {world_cls_name} failed to initialize properly.") + return super().__getattribute__(key) assert getattr(world, "settings_key") == key try: cls_or_name = world.__annotations__["settings"] From 378cc91a4d5204b9e370c95b17551f2f88a77431 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 12 Aug 2025 00:41:43 +0000 Subject: [PATCH 08/34] CI: update appimage runtime (#5315) --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 721d63b1dc..e886ae8230 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ env: APPIMAGETOOL_VERSION: continuous APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684' APPIMAGE_RUNTIME_VERSION: continuous - APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e' + APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' permissions: # permissions required for attestation id-token: 'write' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1462560052..9b7fdd1bcf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ env: APPIMAGETOOL_VERSION: continuous APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684' APPIMAGE_RUNTIME_VERSION: continuous - APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e' + APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' permissions: # permissions required for attestation id-token: 'write' From 9057ce0ce3998b5f2ed54748d32f521052e887fe Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Tue, 12 Aug 2025 16:52:34 +0200 Subject: [PATCH 09/34] WebHost: fix links on sitemap, switch to url_for and add test to prevent future breakage (#5318) --- WebHostLib/templates/siteMap.html | 44 ++++++++++----------- test/general/__init__.py | 9 ++++- test/webhost/test_sitemap.py | 63 +++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 test/webhost/test_sitemap.py diff --git a/WebHostLib/templates/siteMap.html b/WebHostLib/templates/siteMap.html index b7db8227dc..4b764c341a 100644 --- a/WebHostLib/templates/siteMap.html +++ b/WebHostLib/templates/siteMap.html @@ -11,32 +11,32 @@

Site Map

Base Pages

Tutorials

Game Info Pages

diff --git a/test/general/__init__.py b/test/general/__init__.py index 34df741a8c..92ffc77ee6 100644 --- a/test/general/__init__.py +++ b/test/general/__init__.py @@ -3,7 +3,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 +from worlds.AutoWorld import World, WebWorld, call_all gen_steps = ( "generate_early", @@ -17,7 +17,7 @@ gen_steps = ( def setup_solo_multiworld( - world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None + world_type: Type[World], steps: Tuple[str, ...] = gen_steps, seed: Optional[int] = None ) -> MultiWorld: """ Creates a multiworld with a single player of `world_type`, sets default options, and calls provided gen steps. @@ -62,11 +62,16 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple return multiworld +class TestWebWorld(WebWorld): + tutorials = [] + + class TestWorld(World): game = f"Test Game" item_name_to_id = {} location_name_to_id = {} hidden = True + web = TestWebWorld() # add our test world to the data package, so we can test it later diff --git a/test/webhost/test_sitemap.py b/test/webhost/test_sitemap.py new file mode 100644 index 0000000000..930aa32415 --- /dev/null +++ b/test/webhost/test_sitemap.py @@ -0,0 +1,63 @@ +import urllib.parse +import html +import re +from flask import url_for + +import WebHost +from . import TestBase + + +class TestSitemap(TestBase): + + # Codes for OK and some redirects that we use + valid_status_codes = [200, 302, 308] + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + WebHost.copy_tutorials_files_to_static() + + def test_sitemap_route(self) -> None: + """Verify that the sitemap route works correctly and renders the template without errors.""" + with self.app.test_request_context(): + # Test the /sitemap route + with self.client.open("/sitemap") as response: + self.assertEqual(response.status_code, 200) + self.assertIn(b"Site Map", response.data) + + # Test the /index route which should also serve the sitemap + with self.client.open("/index") as response: + self.assertEqual(response.status_code, 200) + self.assertIn(b"Site Map", response.data) + + # Test using url_for with the function name + with self.client.open(url_for('get_sitemap')) as response: + self.assertEqual(response.status_code, 200) + self.assertIn(b'Site Map', response.data) + + def test_sitemap_links(self) -> None: + """ + Verify that all links in the sitemap are valid by making a request to each one. + """ + with self.app.test_request_context(): + with self.client.open(url_for("get_sitemap")) as response: + self.assertEqual(response.status_code, 200) + html_content = response.data.decode() + + # Extract all href links using regex + href_pattern = re.compile(r'href=["\'](.*?)["\']') + links = href_pattern.findall(html_content) + + self.assertTrue(len(links) > 0, "No links found in sitemap") + + # Test each link + for link in links: + # Skip external links + if link.startswith(("http://", "https://")): + continue + + link = urllib.parse.unquote(html.unescape(link)) + + with self.client.open(link) as response, self.subTest(link=link): + self.assertIn(response.status_code, self.valid_status_codes, + f"Link {link} returned invalid status code {response.status_code}") From 85c26f97400a6caaa7a690b9a299e7373c8ace80 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:38:22 +0000 Subject: [PATCH 10/34] WebHost: redirect old tutorials to new URL (#5319) * WebHost: redirect old tutorials to new URL * WebHost: make comment in tutorial_redirect more accurate --- WebHostLib/misc.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index ee85d3defb..c57a638612 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -133,6 +133,15 @@ def tutorial(game: str, file: str): return abort(404) +@app.route('/tutorial///') +def tutorial_redirect(game: str, file: str, lang: str): + """ + Permanent redirect old tutorial URLs to new ones to keep search engines happy. + e.g. /tutorial/Archipelago/setup/en -> /tutorial/Archipelago/setup_en + """ + return redirect(url_for("tutorial", game=game, file=f"{file}_{lang}"), code=301) + + @app.route('/tutorial/') @cache.cached() def tutorial_landing(): From 6e6fd0e9bcc40c7524fe5520a4382e43deb70a14 Mon Sep 17 00:00:00 2001 From: LiquidCat64 <74896918+LiquidCat64@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:01:29 -0600 Subject: [PATCH 11/34] CV64 and CotM: Correct Archipleago (#5323) --- worlds/cv64/__init__.py | 2 +- worlds/cvcotm/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/cv64/__init__.py b/worlds/cv64/__init__.py index 1bd069a2ce..117fd44ea0 100644 --- a/worlds/cv64/__init__.py +++ b/worlds/cv64/__init__.py @@ -37,7 +37,7 @@ class CV64Web(WebWorld): tutorials = [Tutorial( "Multiworld Setup Guide", - "A guide to setting up the Archipleago Castlevania 64 randomizer on your computer and connecting it to a " + "A guide to setting up the Archipelago Castlevania 64 randomizer on your computer and connecting it to a " "multiworld.", "English", "setup_en.md", diff --git a/worlds/cvcotm/__init__.py b/worlds/cvcotm/__init__.py index a2d52b3ecc..829c73ae88 100644 --- a/worlds/cvcotm/__init__.py +++ b/worlds/cvcotm/__init__.py @@ -41,7 +41,7 @@ class CVCotMWeb(WebWorld): tutorials = [Tutorial( "Multiworld Setup Guide", - "A guide to setting up the Archipleago Castlevania: Circle of the Moon randomizer on your computer and " + "A guide to setting up the Archipelago Castlevania: Circle of the Moon randomizer on your computer and " "connecting it to a multiworld.", "English", "setup_en.md", From 0020e6c3d324ec1b74ba5a2db92bb2b464efce39 Mon Sep 17 00:00:00 2001 From: JaredWeakStrike <96694163+JaredWeakStrike@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:35:25 -0400 Subject: [PATCH 12/34] KH2: Fix html headers to be markdown (#5305) * update setup guide * Update worlds/kh2/docs/setup_en.md Co-authored-by: Fabian Dill * Update worlds/kh2/docs/setup_en.md Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> * Update en_Kingdom Hearts 2.md --------- Co-authored-by: Fabian Dill Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- worlds/kh2/docs/en_Kingdom Hearts 2.md | 24 +++++++++---------- worlds/kh2/docs/setup_en.md | 32 +++++++++++++------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/worlds/kh2/docs/en_Kingdom Hearts 2.md b/worlds/kh2/docs/en_Kingdom Hearts 2.md index 5aae7ad3a7..6924800e6c 100644 --- a/worlds/kh2/docs/en_Kingdom Hearts 2.md +++ b/worlds/kh2/docs/en_Kingdom Hearts 2.md @@ -1,15 +1,15 @@ # Kingdom Hearts 2 -

Changes from the vanilla game

+## Changes from the vanilla game This randomizer creates a more dynamic play experience by randomizing the locations of most items in Kingdom Hearts 2. Currently all items within Chests, Popups, Get Bonuses, Form Levels, and Sora's Levels are randomized. This allows abilities that Sora would normally have to be placed on Keyblades with random stats. Additionally, there are several options for ways to finish the game, allowing for different goals beyond beating the final boss. -

Where is the options page

+## Where is the options page The [player options page for this game](../player-options) contains all the options you need to configure and export a config file. -

What is randomized in this game?

+## What is randomized in this game? - Chests @@ -21,27 +21,27 @@ The [player options page for this game](../player-options) contains all the opti - Keyblade Stats - Keyblade Abilities -

What Kingdom Hearts 2 items can appear in other players' worlds?

+## What Kingdom Hearts 2 items can appear in other players' worlds? Every item in the game except for abilities on weapons. -

What is The Garden of Assemblage "GoA"?

+## What is The Garden of Assemblage "GoA"? The Garden of Assemblage Mod made by Sonicshadowsilver2 and Num turns the Garden of Assemblage into a “World Hub” where each portal takes you to one of the game worlds (as opposed to having a world map). This allows you to enter worlds at any time, and world progression is maintained for each world individually. -

What does another world's item look like in Kingdom Hearts 2?

+## What does another world's item look like in Kingdom Hearts 2? In Kingdom Hearts 2, items which need to be sent to other worlds appear in any location that has a item in the vanilla game. They are represented by the Archipelago icon, and must be "picked up" as if it were a normal item. Upon obtaining the item, it will be sent to its home world. -

When the player receives an item, what happens?

+## When the player receives an item, what happens? It is added to your inventory. If you obtain magic, you will need to pause your game to have it show up in your inventory, then enter a new room for it to become properly usable. -

What Happens if I die before Room Saving?

+## What Happens if I die before Room Saving? When you die in vanilla Kingdom Hearts 2, you are reverted to the last non-boss room you entered and your status is reverted to what it was at that time. However, in archipelago, any item that you have sent/received will not be taken away from the player, any chest you have opened will remain open, and you will keep your level, but lose the experience. @@ -49,7 +49,7 @@ When you die in vanilla Kingdom Hearts 2, you are reverted to the last non-boss For example, if you are fighting Roxas, receive Reflect Element, then die mid-fight, you will keep that Reflect Element. You will still need to pause your game to have it show up in your inventory, then enter a new room for it to become properly usable. -

Customization options:

+## Customization options: - Choose a goal from the list below (with an additional option to Kill Final Xemnas alongside your goal). @@ -64,11 +64,11 @@ For example, if you are fighting Roxas, receive Reflect Element, then die mid-fi - Customize the amount and level of progressive movement (Growth Abilities) you start with. - Customize start inventory, i.e., begin every run with certain items or spells of your choice. -

What are Lucky Emblems?

+## What are Lucky Emblems? Lucky Emblems are items that are required to beat the game if your goal is "Lucky Emblem Hunt".
You can think of these as requiring X number of Proofs of Nonexistence to open the final door. -

What is Hitlist/Bounties?

+## What is Hitlist/Bounties? The Hitlist goal adds "bounty" items to select late-game fights and locations, and you need to collect X number of them to win.
The list of possible locations that can contain a bounty: @@ -82,7 +82,7 @@ The list of possible locations that can contain a bounty: - Transport to Remembrance - Godess of Fate cup and Hades Paradox cup -

Quality of life:

+## Quality of life: With the help of Shananas, Num, and ZakTheRobot we have many QoL features such are: diff --git a/worlds/kh2/docs/setup_en.md b/worlds/kh2/docs/setup_en.md index a1248d1095..db0f6c86b9 100644 --- a/worlds/kh2/docs/setup_en.md +++ b/worlds/kh2/docs/setup_en.md @@ -1,11 +1,11 @@ # Kingdom Hearts 2 Archipelago Setup Guide -

Quick Links

+## Quick Links - [Game Info Page](../../../../games/Kingdom%20Hearts%202/info/en) - [Player Options Page](../../../../games/Kingdom%20Hearts%202/player-options) -

Required Software:

+## Required Software: Kingdom Hearts II Final Mix from the [Epic Games Store](https://store.epicgames.com/en-US/discover/kingdom-hearts) or [Steam](https://store.steampowered.com/app/2552430/KINGDOM_HEARTS_HD_1525_ReMIX/) @@ -23,39 +23,39 @@ Kingdom Hearts II Final Mix from the [Epic Games Store](https://store.epicgames. 1. Optionally Install the Archipelago Quality Of Life mod from `JaredWeakStrike/AP_QOL` using OpenKH Mod Manager 2. Optionally Install the Quality Of Life mod from `shananas/BearSkip` using OpenKH Mod Manager -

Required: Archipelago Companion Mod

+### Required: Archipelago Companion Mod Load this mod just like the GoA ROM you did during the KH2 Rando setup. `JaredWeakStrike/APCompanion`
Have this mod second-highest priority below the .zip seed.
This mod is based upon Num's Garden of Assemblage Mod and requires it to work. Without Num this could not be possible. -

Required: Auto Save Mod and KH2 Lua Library

+### Required: Auto Save Mod and KH2 Lua Library -Load these mods just like you loaded the GoA ROM mod during the KH2 Rando setup. `KH2FM-Mods-equations19/auto-save` and `KH2FM-Mods-equations19/KH2-Lua-Library` Location doesn't matter, required in case of crashes. See [Best Practices](en#best-practices) on how to load the auto save +Load these mods just like you loaded the GoA ROM mod during the KH2 Rando setup. `KH2FM-Mods-equations19/auto-save` and `KH2FM-Mods-equations19/KH2-Lua-Library` Location doesn't matter, required in case of crashes. See [Best Practices](#best-practices) on how to load the auto save -

Optional QoL Mods: AP QoL and Bear Skip

+### Optional QoL Mods: AP QoL and Bear Skip `JaredWeakStrike/AP_QOL` Makes the urns minigames much faster, makes Cavern of Remembrance orbs drop significantly more drive orbs for refilling drive/leveling master form, skips the animation when using the bulky vendor RC, skips carpet escape auto-scroller in Agrabah 2, and prevents the wardrobe in the Beasts Castle wardrobe push minigame from waking up while being pushed. `shananas/BearSkip` Skips all minigames in 100 Acre Woods except the Spooky Cave minigame since there are chests in Spooky Cave you can only get during the minigame. For Spooky Cave, Pooh is moved to the other side of the invisible wall that prevents you from using his RC to finish the minigame. -

Installing A Seed

+### Installing A Seed When you generate a game you will see a download link for a KH2 .zip seed on the room page. Download the seed then open OpenKH Mod Manager and click the green plus and "Select and install Mod Archive".
Make sure the seed is on the top of the list (Highest Priority)
After Installing the seed click "Mod Loader -> Build/Build and Run". Every slot is a unique mod to install and will be needed be repatched for different slots/rooms. -

Optional Software:

+## Optional Software: - [Kingdom Hearts 2 AP Tracker](https://github.com/palex00/kh2-ap-tracker/releases/latest/), for use with [PopTracker](https://github.com/black-sliver/PopTracker/releases) -

What the Mod Manager Should Look Like.

+## What the Mod Manager Should Look Like. ![image](https://i.imgur.com/N0WJ8Qn.png) -

Using the KH2 Client

+## Using the KH2 Client Start the game through OpenKH Mod Manager. If starting a new run, enter the Garden of Assemblage from a new save. If returning to a run, load the save and enter the Garden of Assemblage. Then run the [ArchipelagoKH2Client.exe](https://github.com/ArchipelagoMW/Archipelago/releases).
When you successfully connect to the server the client will automatically hook into the game to send/receive checks.
@@ -67,13 +67,13 @@ Most checks will be sent to you anywhere outside a load or cutscene.
If you obtain magic, you will need to pause your game to have it show up in your inventory, then enter a new room for it to become properly usable. -

KH2 Client should look like this:

+## KH2 Client should look like this: ![image](https://i.imgur.com/qP6CmV8.png) Enter The room's port number into the top box where the x's are and press "Connect". Follow the prompts there and you should be connected -

Common Pitfalls

+## Common Pitfalls - Having an old GOA Lua Script in your `C:\Users\*YourName*\Documents\KINGDOM HEARTS HD 1.5+2.5 ReMIX\scripts\kh2` folder. - Pressing F2 while in game should look like this. ![image](https://i.imgur.com/ABSdtPC.png) @@ -86,7 +86,7 @@ Enter The room's port number into the top box where the x's are and pres - Using a seed from the standalone KH2 Randomizer Seed Generator. - The Archipelago version of the KH2 Randomizer does not use this Seed Generator; refer to the [Archipelago Setup](https://archipelago.gg/tutorial/Archipelago/setup/en) to learn how to generate and play a seed through Archipelago. -

Best Practices

+## Best Practices - Make a save at the start of the GoA before opening anything. This will be the file to select when loading an autosave if/when your game crashes. - If you don't want to have a save in the GoA. Disconnect the client, load the auto save, and then reconnect the client after it loads the auto save. @@ -94,13 +94,13 @@ Enter The room's port number into the top box where the x's are and pres - Run the game in windows/borderless windowed mode. Fullscreen is stable but the game can crash if you alt-tab out. - Make sure to save in a different save slot when playing in an async or disconnecting from the server to play a different seed -

Logic Sheet & PopTracker Autotracking

+## Logic Sheet & PopTracker Autotracking Have any questions on what's in logic? This spreadsheet made by Bulcon has the answer [Requirements/logic sheet](https://docs.google.com/spreadsheets/d/1nNi8ohEs1fv-sDQQRaP45o6NoRcMlLJsGckBonweDMY/edit?usp=sharing) Alternatively you can use the Kingdom Hearts 2 PopTracker Pack that is based off of the logic sheet above and does all the work for you. -

PopTracker Pack

+### PopTracker Pack 1. Download [Kingdom Hearts 2 AP Tracker](https://github.com/palex00/kh2-ap-tracker/releases/latest/) and [PopTracker](https://github.com/black-sliver/PopTracker/releases). @@ -112,7 +112,7 @@ Alternatively you can use the Kingdom Hearts 2 PopTracker Pack that is based off This pack will handle logic, received items, checked locations and autotabbing for you! -

F.A.Q.

+## F.A.Q. - Why is my Client giving me a "Cannot Open Process: " error? - Due to how the client reads kingdom hearts 2 memory some people's computer flags it as a virus. Run the client as admin. From 5110676c76277b3bfddb1dde27c8635d853b2172 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 15 Aug 2025 11:44:24 +0200 Subject: [PATCH 13/34] Core: 0.6.4 (#5314) --- Utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Utils.py b/Utils.py index b7616b57b1..04c4ac57e9 100644 --- a/Utils.py +++ b/Utils.py @@ -47,7 +47,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.6.3" +__version__ = "0.6.4" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") From b85887241f96ccef74a836c494ff24b9bf64cbff Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Fri, 15 Aug 2025 10:36:13 +0000 Subject: [PATCH 14/34] CI: update appimagetool hash (#5333) --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e886ae8230..7151ff00c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ env: # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # we check the sha256 and require manual intervention if it was updated. APPIMAGETOOL_VERSION: continuous - APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684' + APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e' APPIMAGE_RUNTIME_VERSION: continuous APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b7fdd1bcf..8c5d87b0ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ env: # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # we check the sha256 and require manual intervention if it was updated. APPIMAGETOOL_VERSION: continuous - APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684' + APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e' APPIMAGE_RUNTIME_VERSION: continuous APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' From 8f7fcd4889002b89f0e0d07364a1faaf269a80b6 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Fri, 15 Aug 2025 05:55:11 -0700 Subject: [PATCH 15/34] Zillion: Move `completion_condition` Definition Earlier (#5279) --- worlds/zillion/__init__.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index 588654d259..02ef920d8f 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -168,8 +168,8 @@ class ZillionWorld(World): def create_regions(self) -> None: assert self.zz_system.randomizer, "generate_early hasn't been called" assert self.id_to_zz_item, "generate_early hasn't been called" - p = self.player - logic_cache = ZillionLogicCache(p, self.zz_system.randomizer, self.id_to_zz_item) + player = self.player + logic_cache = ZillionLogicCache(player, self.zz_system.randomizer, self.id_to_zz_item) self.logic_cache = logic_cache w = self.multiworld self.my_locations = [] @@ -192,7 +192,7 @@ class ZillionWorld(World): all_regions: dict[str, ZillionRegion] = {} for here_zz_name, zz_r in self.zz_system.randomizer.regions.items(): here_name = "Menu" if here_zz_name == "start" else zz_reg_name_to_reg_name(here_zz_name) - all_regions[here_name] = ZillionRegion(zz_r, here_name, here_name, p, w) + all_regions[here_name] = ZillionRegion(zz_r, here_name, here_name, player, w) self.multiworld.regions.append(all_regions[here_name]) limited_skill = Req(gun=3, jump=3, skill=self.zz_system.randomizer.options.skill, hp=940, red=1, floppy=126) @@ -239,7 +239,7 @@ class ZillionWorld(World): for zz_dest in zz_here.connections.keys(): dest_name = "Menu" if zz_dest.name == "start" else zz_reg_name_to_reg_name(zz_dest.name) dest = all_regions[dest_name] - exit_ = Entrance(p, f"{here_name} to {dest_name}", here) + exit_ = Entrance(player, f"{here_name} to {dest_name}", here) here.exits.append(exit_) exit_.connect(dest) @@ -248,6 +248,11 @@ class ZillionWorld(World): if self.options.priority_dead_ends.value: self.options.priority_locations.value |= {loc.name for loc in dead_end_locations} + # main location name is an alias + main_loc_name = self.zz_system.randomizer.loc_name_2_pretty[self.zz_system.randomizer.locations["main"].name] + self.multiworld.get_location(main_loc_name, player).place_locked_item(self.create_item("Win")) + self.multiworld.completion_condition[player] = lambda state: state.has("Win", player) + @override def create_items(self) -> None: if not self.id_to_zz_item: @@ -272,17 +277,6 @@ class ZillionWorld(World): self.logger.debug(f"Zillion Items: {item_name} 1") self.multiworld.itempool.append(self.create_item(item_name)) - @override - def generate_basic(self) -> None: - assert self.zz_system.randomizer, "generate_early hasn't been called" - # main location name is an alias - main_loc_name = self.zz_system.randomizer.loc_name_2_pretty[self.zz_system.randomizer.locations["main"].name] - - self.multiworld.get_location(main_loc_name, self.player)\ - .place_locked_item(self.create_item("Win")) - self.multiworld.completion_condition[self.player] = \ - lambda state: state.has("Win", self.player) - @staticmethod def stage_generate_basic(multiworld: MultiWorld, *args: Any) -> None: # noqa: ANN401 # item link pools are about to be created in main From 9d654b7e3b489d5f1b854dfe24518beb24ba1b8d Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 15 Aug 2025 18:45:40 +0200 Subject: [PATCH 16/34] Core: drop Python 3.10 (#5324) Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- .github/pyright-config.json | 2 +- .github/workflows/analyze-modified-files.yml | 2 +- .github/workflows/unittests.yml | 5 ++--- ModuleUpdate.py | 10 +++++----- Utils.py | 2 +- docs/contributing.md | 2 +- docs/running from source.md | 2 +- worlds/generic/docs/mac_en.md | 2 +- 8 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/pyright-config.json b/.github/pyright-config.json index b6561afa46..64a46d80cc 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -29,7 +29,7 @@ "reportMissingImports": true, "reportMissingTypeStubs": true, - "pythonVersion": "3.10", + "pythonVersion": "3.11", "pythonPlatform": "Windows", "executionEnvironments": [ diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index 6788abd30a..862a050c51 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -53,7 +53,7 @@ jobs: - uses: actions/setup-python@v5 if: env.diff != '' with: - python-version: '3.10' + python-version: '3.11' - name: "Install dependencies" if: env.diff != '' diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 2d83c649e8..96219daa19 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -39,11 +39,10 @@ jobs: matrix: os: [ubuntu-latest] python: - - {version: '3.10'} - - {version: '3.11'} + - {version: '3.11.2'} # Change to '3.11' around 2026-06-10 - {version: '3.12'} include: - - python: {version: '3.10'} # old compat + - python: {version: '3.11'} # old compat os: windows-latest - python: {version: '3.12'} # current os: windows-latest diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 2e58c4f7f9..46064d3f92 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -5,15 +5,15 @@ import multiprocessing import warnings -if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11): +if sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 9): # Official micro version updates. This should match the number in docs/running from source.md. - raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.") -elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15): + raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.11.9+ is supported.") +elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 11, 13): # There are known security issues, but no easy way to install fixed versions on Windows for testing. warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.") -elif sys.version_info < (3, 10, 1): +elif sys.version_info < (3, 11, 0): # Other platforms may get security backports instead of micro updates, so the number is unreliable. - raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.") + raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.11.0+ is supported.") # don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) _skip_update = bool( diff --git a/Utils.py b/Utils.py index 04c4ac57e9..fc8dd7264c 100644 --- a/Utils.py +++ b/Utils.py @@ -900,7 +900,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non Use this to start a task when you don't keep a reference to it or immediately await it, to prevent early garbage collection. "fire-and-forget" """ - # https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task + # https://docs.python.org/3.11/library/asyncio-task.html#asyncio.create_task # Python docs: # ``` # Important: Save a reference to the result of [asyncio.create_task], diff --git a/docs/contributing.md b/docs/contributing.md index 96fc316be8..06d83bebbc 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -16,7 +16,7 @@ game contributions: * **Do not introduce unit test failures/regressions.** Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test your changes. Currently, the oldest supported version - is [Python 3.10](https://www.python.org/downloads/release/python-31015/). + is [Python 3.11](https://www.python.org/downloads/release/python-31113/). It is recommended that automated github actions are turned on in your fork to have github run unit tests after pushing. You can turn them on here: diff --git a/docs/running from source.md b/docs/running from source.md index 8e8b4f4b61..36bff8c8fa 100644 --- a/docs/running from source.md +++ b/docs/running from source.md @@ -7,7 +7,7 @@ use that version. These steps are for developers or platforms without compiled r ## General What you'll need: - * [Python 3.10.11 or newer](https://www.python.org/downloads/), not the Windows Store version + * [Python 3.11.9 or newer](https://www.python.org/downloads/), not the Windows Store version * On Windows, please consider only using the latest supported version in production environments since security updates for older versions are not easily available. * Python 3.12.x is currently the newest supported version diff --git a/worlds/generic/docs/mac_en.md b/worlds/generic/docs/mac_en.md index 38fd3cd940..72f7d1a8b5 100644 --- a/worlds/generic/docs/mac_en.md +++ b/worlds/generic/docs/mac_en.md @@ -2,7 +2,7 @@ Archipelago does not have a compiled release on macOS. However, it is possible to run from source code on macOS. This guide expects you to have some experience with running software from the terminal. ## Prerequisite Software Here is a list of software to install and source code to download. -1. Python 3.10 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/). +1. Python 3.11 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/). **Python 3.13 is not supported yet.** 2. Xcode from the [macOS App Store](https://apps.apple.com/us/app/xcode/id497799835). 3. The source code from the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases). From eb09be35947002edf3c83d5b02736fa652cc7eef Mon Sep 17 00:00:00 2001 From: Faris <162540354+FarisTheAncient@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:08:44 -0500 Subject: [PATCH 17/34] OSRS: Fix UT Integration and Various Gen Failures (#5331) --- worlds/osrs/Locations.py | 2 + worlds/osrs/LogicCSV/LogicCSVToPython.py | 2 +- worlds/osrs/LogicCSV/locations_generated.py | 2 +- worlds/osrs/Options.py | 2 +- worlds/osrs/Rules.py | 2 + worlds/osrs/__init__.py | 94 ++++++++++++++------- 6 files changed, 69 insertions(+), 35 deletions(-) diff --git a/worlds/osrs/Locations.py b/worlds/osrs/Locations.py index b5827d60f2..324da86be4 100644 --- a/worlds/osrs/Locations.py +++ b/worlds/osrs/Locations.py @@ -3,6 +3,8 @@ import typing from BaseClasses import Location +task_types = ["prayer", "magic", "runecraft", "mining", "crafting", "smithing", "fishing", "cooking", "firemaking", "woodcutting", "combat"] + class SkillRequirement(typing.NamedTuple): skill: str level: int diff --git a/worlds/osrs/LogicCSV/LogicCSVToPython.py b/worlds/osrs/LogicCSV/LogicCSVToPython.py index b66f53cc9d..082bda7a08 100644 --- a/worlds/osrs/LogicCSV/LogicCSVToPython.py +++ b/worlds/osrs/LogicCSV/LogicCSVToPython.py @@ -8,7 +8,7 @@ import requests # The CSVs are updated at this repository to be shared between generator and client. data_repository_address = "https://raw.githubusercontent.com/digiholic/osrs-archipelago-logic/" # The Github tag of the CSVs this was generated with -data_csv_tag = "v2.0.4" +data_csv_tag = "v2.0.5" # If true, generate using file names in the repository debug = False diff --git a/worlds/osrs/LogicCSV/locations_generated.py b/worlds/osrs/LogicCSV/locations_generated.py index 4c1cd0bdd8..03156b1c71 100644 --- a/worlds/osrs/LogicCSV/locations_generated.py +++ b/worlds/osrs/LogicCSV/locations_generated.py @@ -77,7 +77,7 @@ location_rows = [ LocationRow('Bake a Redberry Pie', 'cooking', ['Redberry Bush', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 10), ], [], 0), LocationRow('Cook some Stew', 'cooking', ['Bowl', 'Meat', 'Potato', ], [SkillRequirement('Cooking', 25), ], [], 0), LocationRow('Bake an Apple Pie', 'cooking', ['Cooking Apple', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 32), ], [], 2), - LocationRow('Enter the Cook\'s Guild', 'cooking', ['Cook\'s Guild', ], [], [], 0), + LocationRow('Enter the Cook\'s Guild', 'cooking', ['Cook\'s Guild', ], [SkillRequirement('Cooking', 32), ], [], 0), LocationRow('Bake a Cake', 'cooking', ['Wheat', 'Windmill', 'Egg', 'Milk', 'Cake Tin', ], [SkillRequirement('Cooking', 40), ], [], 6), LocationRow('Bake a Meat Pizza', 'cooking', ['Wheat', 'Windmill', 'Cheese', 'Tomato', 'Meat', ], [SkillRequirement('Cooking', 45), ], [], 8), LocationRow('Burn a Log', 'firemaking', [], [SkillRequirement('Firemaking', 1), SkillRequirement('Woodcutting', 1), ], [], 0), diff --git a/worlds/osrs/Options.py b/worlds/osrs/Options.py index 55a040b095..cf0754a3c2 100644 --- a/worlds/osrs/Options.py +++ b/worlds/osrs/Options.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from Options import Choice, Toggle, Range, PerGameCommonOptions -MAX_COMBAT_TASKS = 16 +MAX_COMBAT_TASKS = 17 MAX_PRAYER_TASKS = 5 MAX_MAGIC_TASKS = 7 diff --git a/worlds/osrs/Rules.py b/worlds/osrs/Rules.py index 7fd770f0f7..1cdaabe3e2 100644 --- a/worlds/osrs/Rules.py +++ b/worlds/osrs/Rules.py @@ -190,6 +190,8 @@ def get_firemaking_skill_rule(level, player, options) -> CollectionRule: def get_skill_rule(skill, level, player, options) -> CollectionRule: + if level <= 1: + return lambda state: True if skill.lower() == "fishing": return get_fishing_skill_rule(level, player, options) if skill.lower() == "mining": diff --git a/worlds/osrs/__init__.py b/worlds/osrs/__init__.py index a54e272d05..e0587daff3 100644 --- a/worlds/osrs/__init__.py +++ b/worlds/osrs/__init__.py @@ -1,11 +1,11 @@ import typing -from BaseClasses import Item, Tutorial, ItemClassification, Region, MultiWorld, CollectionState -from Fill import fill_restrictive, FillError +from BaseClasses import Item, Tutorial, ItemClassification, Region, MultiWorld from worlds.AutoWorld import WebWorld, World +from Options import OptionError from .Items import OSRSItem, starting_area_dict, chunksanity_starting_chunks, QP_Items, ItemRow, \ chunksanity_special_region_names -from .Locations import OSRSLocation, LocationRow +from .Locations import OSRSLocation, LocationRow, task_types from .Rules import * from .Options import OSRSOptions, StartingArea from .Names import LocationNames, ItemNames, RegionNames @@ -47,6 +47,7 @@ class OSRSWorld(World): base_id = 0x070000 data_version = 1 explicit_indirect_conditions = False + ut_can_gen_without_yaml = True item_name_to_id = {item_rows[i].name: 0x070000 + i for i in range(len(item_rows))} location_name_to_id = {location_rows[i].name: 0x070000 + i for i in range(len(location_rows))} @@ -105,6 +106,18 @@ class OSRSWorld(World): # Set Starting Chunk self.multiworld.push_precollected(self.create_item(self.starting_area_item)) + elif hasattr(self.multiworld,"re_gen_passthrough") and self.game in self.multiworld.re_gen_passthrough: + re_gen_passthrough = self.multiworld.re_gen_passthrough[self.game] # UT passthrough + if "starting_area" in re_gen_passthrough: + self.starting_area_item = re_gen_passthrough["starting_area"] + for task_type in task_types: + if f"max_{task_type}_level" in re_gen_passthrough: + getattr(self.options,f"max_{task_type}_level").value = re_gen_passthrough[f"max_{task_type}_level"] + max_count = getattr(self.options,f"max_{task_type}_tasks") + max_count.value = max_count.range_end + self.options.brutal_grinds.value = re_gen_passthrough["brutal_grinds"] + + """ This function pulls from LogicCSVToPython so that it sends the correct tag of the repository to the client. @@ -115,20 +128,13 @@ class OSRSWorld(World): data = self.options.as_dict("brutal_grinds") data["data_csv_tag"] = data_csv_tag data["starting_area"] = str(self.starting_area_item) #these aren't actually strings, they just play them on tv + for task_type in task_types: + data[f"max_{task_type}_level"] = getattr(self.options,f"max_{task_type}_level").value return data - def interpret_slot_data(self, slot_data: typing.Dict[str, typing.Any]) -> None: - if "starting_area" in slot_data: - self.starting_area_item = slot_data["starting_area"] - menu_region = self.multiworld.get_region("Menu",self.player) - menu_region.exits.clear() #prevent making extra exits if players just reconnect to a differnet slot - if self.starting_area_item in chunksanity_special_region_names: - starting_area_region = chunksanity_special_region_names[self.starting_area_item] - else: - starting_area_region = self.starting_area_item[6:] # len("Area: ") - starting_entrance = menu_region.create_exit(f"Start->{starting_area_region}") - starting_entrance.access_rule = lambda state: state.has(self.starting_area_item, self.player) - starting_entrance.connect(self.region_name_to_data[starting_area_region]) + @staticmethod + def interpret_slot_data(slot_data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return slot_data def create_regions(self) -> None: """ @@ -195,6 +201,8 @@ class OSRSWorld(World): generation_is_fake = hasattr(self.multiworld, "generation_is_fake") # UT specific override locations_required = 0 for item_row in item_rows: + if item_row.name == self.starting_area_item: + continue #skip starting area # If it's a filler item, set it aside for later if item_row.progression == ItemClassification.filler: continue @@ -206,15 +214,18 @@ class OSRSWorld(World): locations_required += item_row.amount if self.options.enable_duds: locations_required += self.options.dud_count - locations_added = 1 # At this point we've already added the starting area, so we start at 1 instead of 0 - + locations_added = 0 # Keep track of the number of locations we add so we don't add more the number of items we're going to make # Quests are always added first, before anything else is rolled for i, location_row in enumerate(location_rows): - if location_row.category in {"quest", "points", "goal"}: + if location_row.category in {"quest"}: if self.task_within_skill_levels(location_row.skills): self.create_and_add_location(i) - if location_row.category == "quest": - locations_added += 1 + locations_added += 1 + elif location_row.category in {"goal"}: + if not self.task_within_skill_levels(location_row.skills): + raise OptionError(f"Goal location for {self.player_name} not allowed in skill levels") #it doesn't actually have any, but just in case for future + self.create_and_add_location(i) + # Build up the weighted Task Pool rnd = self.random @@ -225,18 +236,28 @@ class OSRSWorld(World): rnd.shuffle(general_tasks) else: general_tasks.reverse() - for i in range(self.options.minimum_general_tasks): + general_tasks_added = 0 + while general_tasks_added0: + task = general_tasks.pop() + if self.task_within_skill_levels(task.skills): + self.add_location(task) + locations_added += 1 + general_tasks_added += 1 + if general_tasks_added < self.options.minimum_general_tasks: + raise OptionError(f"{self.plyaer_name} doesn't have enough general tasks to create required minimum count"+ + f", raise maximum skill levels or lower minimum general tasks") - general_weight = self.options.general_task_weight if len(general_tasks) > 0 else 0 + general_weight = self.options.general_task_weight.value if len(general_tasks) > 0 else 0 tasks_per_task_type: typing.Dict[str, typing.List[LocationRow]] = {} weights_per_task_type: typing.Dict[str, int] = {} - - task_types = ["prayer", "magic", "runecraft", "mining", "crafting", - "smithing", "fishing", "cooking", "firemaking", "woodcutting", "combat"] + for task_type in task_types: max_amount_for_task_type = getattr(self.options, f"max_{task_type}_tasks") tasks_for_this_type = [task for task in self.locations_by_category[task_type] @@ -263,10 +284,13 @@ class OSRSWorld(World): all_weights.append(weights_per_task_type[task_type]) # Even after the initial forced generals, they can still be rolled randomly - if general_weight > 0: + if general_weight > 0 and len(general_tasks)>0: all_tasks.append(general_tasks) all_weights.append(general_weight) + if not generation_is_fake and locations_added > locations_required: #due to minimum general tasks we already have more than needed + raise OptionError(f"Too many locations created for {self.player_name}, lower the minimum general tasks") + while locations_added < locations_required or (generation_is_fake and len(all_tasks) > 0): if all_tasks: chosen_task = rnd.choices(all_tasks, all_weights)[0] @@ -282,9 +306,9 @@ class OSRSWorld(World): del all_tasks[index] del all_weights[index] - else: + else: # We can ignore general tasks in UT because they will have been cleared already if len(general_tasks) == 0: - raise Exception(f"There are not enough available tasks to fill the remaining pool for OSRS " + + raise OptionError(f"There are not enough available tasks to fill the remaining pool for OSRS " + f"Please adjust {self.player_name}'s settings to be less restrictive of tasks.") task = general_tasks.pop() self.add_location(task) @@ -296,7 +320,7 @@ class OSRSWorld(World): self.create_and_add_location(index) def create_items(self) -> None: - filler_items = [] + filler_items:list[ItemRow] = [] for item_row in item_rows: if item_row.name != self.starting_area_item: # If it's a filler item, set it aside for later @@ -321,7 +345,7 @@ class OSRSWorld(World): def get_filler_item_name(self) -> str: if self.options.enable_duds: - return self.random.choice([item for item in item_rows if item.progression == ItemClassification.filler]) + return self.random.choice([item.name for item in item_rows if item.progression == ItemClassification.filler]) else: return self.random.choice([ItemNames.Progressive_Weapons, ItemNames.Progressive_Magic, ItemNames.Progressive_Range_Weapon, ItemNames.Progressive_Armor, @@ -388,6 +412,12 @@ class OSRSWorld(World): # Set the access rule for the QP Location add_rule(qp_loc, lambda state, loc=q_loc: (loc.can_reach(state))) + qp = 0 + for qp_event in self.available_QP_locations: + qp += int(qp_event[0]) + if qp < self.location_rows_by_name[LocationNames.Q_Dragon_Slayer].qp: + raise OptionError(f"{self.player_name} doesn't have enough quests for reach goal, increase maximum skill levels") + # place "Victory" at "Dragon Slayer" and set collection as win condition self.multiworld.get_location(LocationNames.Q_Dragon_Slayer, self.player) \ .place_locked_item(self.create_event("Victory")) From 6f7ca082f22b350c9fc7dfea3e6357635c2da60b Mon Sep 17 00:00:00 2001 From: Flit <8645405+FlitPix@users.noreply.github.com> Date: Sun, 17 Aug 2025 14:47:01 -0400 Subject: [PATCH 18/34] Docker: use python:3.12-slim-bookworm (#5343) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9e3c5f0d71..294767beb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ COPY intset.h . RUN cythonize -b -i _speedups.pyx # Archipelago -FROM python:3.12-slim AS archipelago +FROM python:3.12-slim-bookworm AS archipelago ARG TARGETARCH ENV VIRTUAL_ENV=/opt/venv ENV PYTHONUNBUFFERED=1 From 6ba2b7f8c36e3f2945223183d99c043f0069a8c9 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Sun, 17 Aug 2025 19:46:48 -0500 Subject: [PATCH 19/34] Tests: implement pattern for filtering unittests locally (#5080) --- test/worlds/__init__.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/test/worlds/__init__.py b/test/worlds/__init__.py index 4bc017511c..4a4e3e07c1 100644 --- a/test/worlds/__init__.py +++ b/test/worlds/__init__.py @@ -1,17 +1,46 @@ -def load_tests(loader, standard_tests, pattern): +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from unittest import TestLoader, TestSuite + + +def load_tests(loader: "TestLoader", standard_tests: "TestSuite", pattern: str): import os import unittest + import fnmatch from .. import file_path from worlds.AutoWorld import AutoWorldRegister suite = unittest.TestSuite() suite.addTests(standard_tests) + + # pattern hack + # all tests from within __init__ are always imported, so we need to filter out the folder earlier + # if the pattern isn't matching a specific world, we don't have much of a solution + + if pattern.startswith("worlds."): + if pattern.endswith(".py"): + pattern = pattern[:-3] + components = pattern.split(".") + world_glob = f"worlds.{components[1]}" + pattern = components[-1] + + elif pattern.startswith(f"worlds{os.path.sep}") or pattern.startswith(f"worlds{os.path.altsep}"): + components = pattern.split(os.path.sep) + if len(components) == 1: + components = pattern.split(os.path.altsep) + world_glob = f"worlds.{components[1]}" + pattern = components[-1] + else: + world_glob = "*" + + folders = [os.path.join(os.path.split(world.__file__)[0], "test") - for world in AutoWorldRegister.world_types.values()] + for world in AutoWorldRegister.world_types.values() + if fnmatch.fnmatch(world.__module__, world_glob)] all_tests = [ test_case for folder in folders if os.path.exists(folder) - for test_collection in loader.discover(folder, top_level_dir=file_path) + for test_collection in loader.discover(folder, top_level_dir=file_path, pattern=pattern) for test_suite in test_collection if isinstance(test_suite, unittest.suite.TestSuite) for test_case in test_suite ] From 9a64b8c5cecc91efbbda7cb300bfad0cca296552 Mon Sep 17 00:00:00 2001 From: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> Date: Sun, 17 Aug 2025 20:48:56 -0400 Subject: [PATCH 20/34] Webhost: Remove showdown.js Remnants (#4984) --- WebHostLib/static/styles/markdown.css | 6 ------ 1 file changed, 6 deletions(-) diff --git a/WebHostLib/static/styles/markdown.css b/WebHostLib/static/styles/markdown.css index 5ead2c60f7..ac06dea59d 100644 --- a/WebHostLib/static/styles/markdown.css +++ b/WebHostLib/static/styles/markdown.css @@ -28,7 +28,6 @@ font-weight: normal; font-family: LondrinaSolid-Regular, sans-serif; text-transform: uppercase; - cursor: pointer; /* TODO: remove once we drop showdown.js */ width: 100%; text-shadow: 1px 1px 4px #000000; } @@ -37,7 +36,6 @@ font-size: 38px; font-weight: normal; font-family: LondrinaSolid-Light, sans-serif; - cursor: pointer; /* TODO: remove once we drop showdown.js */ width: 100%; margin-top: 20px; margin-bottom: 0.5rem; @@ -50,7 +48,6 @@ font-family: LexendDeca-Regular, sans-serif; text-transform: none; text-align: left; - cursor: pointer; /* TODO: remove once we drop showdown.js */ width: 100%; margin-bottom: 0.5rem; } @@ -59,7 +56,6 @@ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 24px; - cursor: pointer; /* TODO: remove once we drop showdown.js */ margin-bottom: 24px; } @@ -67,14 +63,12 @@ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 22px; - cursor: pointer; /* TODO: remove once we drop showdown.js */ } .markdown h6, .markdown details summary.h6{ font-family: LexendDeca-Regular, sans-serif; text-transform: none; font-size: 20px; - cursor: pointer; /* TODO: remove once we drop showdown.js */ } .markdown h4, .markdown h5, .markdown h6{ From 48906de8733d9a498484810c89a38f0865ec28d7 Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:08:39 -0400 Subject: [PATCH 21/34] Jak and Daxter: fix checks getting lost if player disconnects. (#5280) --- worlds/jakanddaxter/client.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/worlds/jakanddaxter/client.py b/worlds/jakanddaxter/client.py index 90e6a42aa7..fa2aea5495 100644 --- a/worlds/jakanddaxter/client.py +++ b/worlds/jakanddaxter/client.py @@ -135,6 +135,10 @@ class JakAndDaxterContext(CommonContext): self.tags = set() await self.send_connect() + async def disconnect(self, allow_autoreconnect: bool = False): + self.locations_checked = set() # Clear this set to gracefully handle server disconnects. + await super(JakAndDaxterContext, self).disconnect(allow_autoreconnect) + def on_package(self, cmd: str, args: dict): if cmd == "RoomInfo": @@ -177,6 +181,10 @@ class JakAndDaxterContext(CommonContext): create_task_log_exception(get_orb_balance()) + # If there were any locations checked while the client wasn't connected, we want to make sure the server + # knows about them. To do that, replay the whole location_outbox (no duplicates will be sent). + self.memr.outbox_index = 0 + # Tell the server if Deathlink is enabled or disabled in the in-game options. # This allows us to "remember" the user's choice. self.on_deathlink_toggle() @@ -254,6 +262,7 @@ class JakAndDaxterContext(CommonContext): # We don't need an ap_inform function because check_locations solves that need. def on_location_check(self, location_ids: list[int]): + self.locations_checked.update(location_ids) # Populate this set to gracefully handle server disconnects. create_task_log_exception(self.check_locations(location_ids)) # CommonClient has no finished_game function, so we will have to craft our own. TODO - Update if that changes. From 16d5b453a79c7ef869e20c1e27b3cf4192b037b3 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:35:50 +0000 Subject: [PATCH 22/34] Core: require setuptools>=75 (#5346) Setuptools 70.3.0 seems to not work for us. --- Dockerfile | 2 +- ModuleUpdate.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 294767beb2..363478988c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ COPY requirements.txt WebHostLib/requirements.txt RUN pip install --no-cache-dir -r \ WebHostLib/requirements.txt \ - "setuptools<81" + "setuptools>=75,<81" COPY _speedups.pyx . COPY intset.h . diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 46064d3f92..db42f8e5ab 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -74,11 +74,11 @@ def update_command(): def install_pkg_resources(yes=False): try: import pkg_resources # noqa: F401 - except ImportError: + except (AttributeError, ImportError): check_pip() if not yes: confirm("pkg_resources not found, press enter to install it") - subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools<81"]) + subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools>=75,<81"]) def update(yes: bool = False, force: bool = False) -> None: diff --git a/setup.py b/setup.py index 1808b22c62..01342e4ece 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ try: install_cx_freeze = False except pkg_resources.ResolutionError: install_cx_freeze = True -except ImportError: +except (AttributeError, ImportError): install_cx_freeze = True pkg_resources = None # type: ignore[assignment] From bead81b64b067c03489c135b326cba1aa5772443 Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Wed, 20 Aug 2025 23:46:06 -0600 Subject: [PATCH 23/34] Core: Fix get_unique_identifier failing on missing cache folder (#5322) --- Utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Utils.py b/Utils.py index fc8dd7264c..5a24bc157e 100644 --- a/Utils.py +++ b/Utils.py @@ -414,11 +414,11 @@ def get_adjuster_settings(game_name: str) -> Namespace: @cache_argsless def get_unique_identifier(): common_path = cache_path("common.json") - if os.path.exists(common_path): + try: with open(common_path) as f: common_file = json.load(f) uuid = common_file.get("uuid", None) - else: + except FileNotFoundError: common_file = {} uuid = None @@ -428,6 +428,9 @@ def get_unique_identifier(): from uuid import uuid4 uuid = str(uuid4()) common_file["uuid"] = uuid + + cache_folder = os.path.dirname(common_path) + os.makedirs(cache_folder, exist_ok=True) with open(common_path, "w") as f: json.dump(common_file, f, separators=(",", ":")) return uuid From 88a4a589a0ae4f3f38e6de35772d7bdc3c61a761 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Sat, 23 Aug 2025 01:33:46 -0500 Subject: [PATCH 24/34] WebHost: add a tracker api endpoint (#1052) An endpoint from the tracker page. --- WebHostLib/api/__init__.py | 4 +- WebHostLib/api/tracker.py | 230 +++++++++++++++++++++++++++++++++++++ docs/webhost api.md | 210 +++++++++++++++++++++++++++++++++ 3 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 WebHostLib/api/tracker.py diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index d0b9d05c16..54eb5c1de1 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -11,5 +11,5 @@ api_endpoints = Blueprint('api', __name__, url_prefix="/api") def get_players(seed: Seed) -> List[Tuple[str, str]]: return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)] - -from . import datapackage, generate, room, user # trigger registration +# trigger endpoint registration +from . import datapackage, generate, room, tracker, user diff --git a/WebHostLib/api/tracker.py b/WebHostLib/api/tracker.py new file mode 100644 index 0000000000..abf4cdbe1b --- /dev/null +++ b/WebHostLib/api/tracker.py @@ -0,0 +1,230 @@ +from datetime import datetime, timezone +from typing import Any, TypedDict +from uuid import UUID + +from flask import abort + +from NetUtils import ClientStatus, Hint, NetworkItem, SlotType +from WebHostLib import cache +from WebHostLib.api import api_endpoints +from WebHostLib.models import Room +from WebHostLib.tracker import TrackerData + + +@api_endpoints.route("/tracker/") +@cache.memoize(timeout=60) +def tracker_data(tracker: UUID) -> dict[str, Any]: + """ + Outputs json data to /api/tracker/. + + :param tracker: UUID of current session tracker. + + :return: Tracking data for all players in the room. Typing and docstrings describe the format of each value. + """ + room: Room | None = Room.get(tracker=tracker) + if not room: + abort(404) + + tracker_data = TrackerData(room) + + all_players: dict[int, list[int]] = tracker_data.get_all_players() + + class PlayerAlias(TypedDict): + player: int + name: str | None + + player_aliases: list[dict[str, int | list[PlayerAlias]]] = [] + """Slot aliases of all players.""" + for team, players in all_players.items(): + team_player_aliases: list[PlayerAlias] = [] + team_aliases = {"team": team, "players": team_player_aliases} + player_aliases.append(team_aliases) + for player in players: + team_player_aliases.append({"player": player, "alias": tracker_data.get_player_alias(team, player)}) + + class PlayerItemsReceived(TypedDict): + player: int + items: list[NetworkItem] + + player_items_received: list[dict[str, int | list[PlayerItemsReceived]]] = [] + """Items received by each player.""" + for team, players in all_players.items(): + player_received_items: list[PlayerItemsReceived] = [] + team_items_received = {"team": team, "players": player_received_items} + player_items_received.append(team_items_received) + for player in players: + player_received_items.append( + {"player": player, "items": tracker_data.get_player_received_items(team, player)}) + + class PlayerChecksDone(TypedDict): + player: int + locations: list[int] + + player_checks_done: list[dict[str, int | list[PlayerChecksDone]]] = [] + """ID of all locations checked by each player.""" + for team, players in all_players.items(): + per_player_checks: list[PlayerChecksDone] = [] + team_checks_done = {"team": team, "players": per_player_checks} + player_checks_done.append(team_checks_done) + for player in players: + per_player_checks.append( + {"player": player, "locations": sorted(tracker_data.get_player_checked_locations(team, player))}) + + total_checks_done: list[dict[str, int]] = [ + {"team": team, "checks_done": checks_done} + for team, checks_done in tracker_data.get_team_locations_checked_count().items() + ] + """Total number of locations checked for the entire multiworld per team.""" + + class PlayerHints(TypedDict): + player: int + hints: list[Hint] + + hints: list[dict[str, int | list[PlayerHints]]] = [] + """Hints that all players have used or received.""" + for team, players in tracker_data.get_all_slots().items(): + per_player_hints: list[PlayerHints] = [] + team_hints = {"team": team, "players": per_player_hints} + hints.append(team_hints) + for player in players: + player_hints = sorted(tracker_data.get_player_hints(team, player)) + per_player_hints.append({"player": player, "hints": player_hints}) + slot_info = tracker_data.get_slot_info(team, player) + # this assumes groups are always after players + if slot_info.type != SlotType.group: + continue + for member in slot_info.group_members: + team_hints[member]["hints"] += player_hints + + class PlayerTimer(TypedDict): + player: int + time: datetime | None + + activity_timers: list[dict[str, int | list[PlayerTimer]]] = [] + """Time of last activity per player. Returned as RFC 1123 format and null if no connection has been made.""" + for team, players in all_players.items(): + player_timers: list[PlayerTimer] = [] + team_timers = {"team": team, "players": player_timers} + activity_timers.append(team_timers) + for player in players: + player_timers.append({"player": player, "time": None}) + + client_activity_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get("client_activity_timers", ()) + for (team, player), timestamp in client_activity_timers: + # use index since we can rely on order + activity_timers[team]["player_timers"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc) + + connection_timers: list[dict[str, int | list[PlayerTimer]]] = [] + """Time of last connection per player. Returned as RFC 1123 format and null if no connection has been made.""" + for team, players in all_players.items(): + player_timers: list[PlayerTimer] = [] + team_connection_timers = {"team": team, "players": player_timers} + connection_timers.append(team_connection_timers) + for player in players: + player_timers.append({"player": player, "time": None}) + + client_connection_timers: tuple[tuple[int, int], float] = tracker_data._multisave.get( + "client_connection_timers", ()) + for (team, player), timestamp in client_connection_timers: + connection_timers[team]["players"][player - 1]["time"] = datetime.fromtimestamp(timestamp, timezone.utc) + + class PlayerStatus(TypedDict): + player: int + status: ClientStatus + + player_status: list[dict[str, int | list[PlayerStatus]]] = [] + """The current client status for each player.""" + for team, players in all_players.items(): + player_statuses: list[PlayerStatus] = [] + team_status = {"team": team, "players": player_statuses} + player_status.append(team_status) + for player in players: + player_statuses.append({"player": player, "status": tracker_data.get_player_client_status(team, player)}) + + return { + **get_static_tracker_data(room), + "aliases": player_aliases, + "player_items_received": player_items_received, + "player_checks_done": player_checks_done, + "total_checks_done": total_checks_done, + "hints": hints, + "activity_timers": activity_timers, + "connection_timers": connection_timers, + "player_status": player_status, + "datapackage": tracker_data._multidata["datapackage"], + } + +@cache.memoize() +def get_static_tracker_data(room: Room) -> dict[str, Any]: + """ + Builds and caches the static data for this active session tracker, so that it doesn't need to be recalculated. + """ + + tracker_data = TrackerData(room) + + all_players: dict[int, list[int]] = tracker_data.get_all_players() + + class PlayerGroups(TypedDict): + slot: int + name: str + members: list[int] + + groups: list[dict[str, int | list[PlayerGroups]]] = [] + """The Slot ID of groups and the IDs of the group's members.""" + for team, players in tracker_data.get_all_slots().items(): + groups_in_team: list[PlayerGroups] = [] + team_groups = {"team": team, "groups": groups_in_team} + groups.append(team_groups) + for player in players: + slot_info = tracker_data.get_slot_info(team, player) + if slot_info.type != SlotType.group or not slot_info.group_members: + continue + groups_in_team.append( + { + "slot": player, + "name": slot_info.name, + "members": list(slot_info.group_members), + }) + class PlayerName(TypedDict): + player: int + name: str + + player_names: list[dict[str, str | list[PlayerName]]] = [] + """Slot names of all players.""" + for team, players in all_players.items(): + per_team_player_names: list[PlayerName] = [] + team_names = {"team": team, "players": per_team_player_names} + player_names.append(team_names) + for player in players: + per_team_player_names.append({"player": player, "name": tracker_data.get_player_name(team, player)}) + + class PlayerGame(TypedDict): + player: int + game: str + + games: list[dict[str, int | list[PlayerGame]]] = [] + """The game each player is playing.""" + for team, players in all_players.items(): + player_games: list[PlayerGame] = [] + team_games = {"team": team, "players": player_games} + games.append(team_games) + for player in players: + player_games.append({"player": player, "game": tracker_data.get_player_game(team, player)}) + + class PlayerSlotData(TypedDict): + player: int + slot_data: dict[str, Any] + + slot_data: list[dict[str, int | list[PlayerSlotData]]] = [] + """Slot data for each player.""" + for team, players in all_players.items(): + player_slot_data: list[PlayerSlotData] = [] + team_slot_data = {"team": team, "players": player_slot_data} + slot_data.append(team_slot_data) + for player in players: + player_slot_data.append({"player": player, "slot_data": tracker_data.get_slot_data(team, player)}) + + return { + "groups": groups, + "slot_data": slot_data, + } diff --git a/docs/webhost api.md b/docs/webhost api.md index c8936205ec..ca4b1ce715 100644 --- a/docs/webhost api.md +++ b/docs/webhost api.md @@ -16,6 +16,8 @@ Current endpoints: - [`/status/`](#status) - Room API - [`/room_status/`](#roomstatus) +- Tracker API + - [`/tracker/`](#tracker) - User API - [`/get_rooms`](#getrooms) - [`/get_seeds`](#getseeds) @@ -244,6 +246,214 @@ Example: } ``` +## Tracker Endpoints +Endpoints to fetch information regarding players of an active WebHost room with the supplied tracker_ID. The tracker ID +can either be viewed while on a room tracker page, or from the [room's endpoint](#room-endpoints). + +### `/tracker/` + +Will provide a dict of tracker data with the following keys: + +- item_link groups and their players (`groups`) +- Each player's slot_data (`slot_data`) +- Each player's current alias (`aliases`) + - Will return the name if there is none +- A list of items each player has received as a NetworkItem (`player_items_received`) +- A list of checks done by each player as a list of the location id's (`player_checks_done`) +- The total number of checks done by all players (`total_checks_done`) +- Hints that players have used or received (`hints`) +- The time of last activity of each player in RFC 1123 format (`activity_timers`) +- The time of last active connection of each player in RFC 1123 format (`connection_timers`) +- The current client status of each player (`player_status`) +- The datapackage hash for each player (`datapackage`) + - This hash can then be sent to the datapackage API to receive the appropriate datapackage as necessary + + +Example: +```json +{ + "groups": [ + { + "team": 0, + "groups": [ + { + "slot": 5, + "name": "testGroup", + "members": [ + 1, + 2 + ] + }, + { + "slot": 6, + "name": "myCoolLink", + "members": [ + 3, + 4 + ] + } + ] + } + ], + "slot_data": [ + { + "team": 0, + "players": [ + { + "player": 1, + "slot_data": { + "example_option": 1, + "other_option": 3 + } + }, + { + "player": 2, + "slot_data": { + "example_option": 1, + "other_option": 2 + } + } + ] + } + ], + "aliases": [ + { + "team": 0, + "players": [ + { + "player": 1, + "alias": "Incompetence" + }, + { + "player": 2, + "alias": "Slot_Name_2" + } + ] + } + ], + "player_items_received": [ + { + "team": 0, + "players": [ + { + "player": 1, + "items": [ + [1, 1, 1, 0], + [2, 2, 2, 1] + ] + }, + { + "player": 2, + "items": [ + [1, 1, 1, 2], + [2, 2, 2, 0] + ] + } + ] + } + ], + "player_checks_done": [ + { + "team": 0, + "players": [ + { + "player": 1, + "locations": [ + 1, + 2 + ] + }, + { + "player": 2, + "locations": [ + 1, + 2 + ] + } + ] + } + ], + "total_checks_done": [ + { + "team": 0, + "checks_done": 4 + } + ], + "hints": [ + { + "team": 0, + "players": [ + { + "player": 1, + "hints": [ + [1, 2, 4, 6, 0, "", 4, 0] + ] + }, + { + "player": 2, + "hints": [] + } + ] + } + ], + "activity_timers": [ + { + "team": 0, + "players": [ + { + "player": 1, + "time": "Fri, 18 Apr 2025 20:35:45 GMT" + }, + { + "player": 2, + "time": "Fri, 18 Apr 2025 20:42:46 GMT" + } + ] + } + ], + "connection_timers": [ + { + "team": 0, + "players": [ + { + "player": 1, + "time": "Fri, 18 Apr 2025 20:38:25 GMT" + }, + { + "player": 2, + "time": "Fri, 18 Apr 2025 21:03:00 GMT" + } + ] + } + ], + "player_status": [ + { + "team": 0, + "players": [ + { + "player": 1, + "status": 0 + }, + { + "player": 2, + "status": 0 + } + ] + } + ], + "datapackage": { + "Archipelago": { + "checksum": "ac9141e9ad0318df2fa27da5f20c50a842afeecb", + "version": 0 + }, + "The Messenger": { + "checksum": "6991cbcda7316b65bcb072667f3ee4c4cae71c0b", + "version": 0 + } + } +} +``` + ## User Endpoints User endpoints can get room and seed details from the current session tokens (cookies) From dfd7cbf0c5c11bb2ff126349395b9cbf1f81f297 Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Sat, 23 Aug 2025 18:36:25 -0400 Subject: [PATCH 25/34] Tests: Standardize World Exclusions, Strengthen LCS Test (#4423) --- test/general/test_implemented.py | 9 +++++---- test/general/test_items.py | 3 +-- test/general/test_locations.py | 9 ++++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py index cf0624a288..de432e3690 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -37,10 +37,11 @@ class TestImplemented(unittest.TestCase): def test_slot_data(self): """Tests that if a world creates slot data, it's json serializable.""" - for game_name, world_type in AutoWorldRegister.world_types.items(): - # has an await for generate_output which isn't being called - if game_name in {"Ocarina of Time"}: - continue + # has an await for generate_output which isn't being called + excluded_games = ("Ocarina of Time",) + worlds_to_test = {game: world + for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games} + for game_name, world_type in worlds_to_test.items(): multiworld = setup_solo_multiworld(world_type) with self.subTest(game=game_name, seed=multiworld.seed): distribute_items_restrictive(multiworld) diff --git a/test/general/test_items.py b/test/general/test_items.py index dbaca1c91c..a48576da52 100644 --- a/test/general/test_items.py +++ b/test/general/test_items.py @@ -150,8 +150,7 @@ class TestBase(unittest.TestCase): """Test that worlds don't modify the locality of items after duplicates are resolved""" gen_steps = ("generate_early",) additional_steps = ("create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic", "pre_fill") - worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()} - for game_name, world_type in worlds_to_test.items(): + for game_name, world_type in AutoWorldRegister.world_types.items(): with self.subTest("Game", game=game_name): multiworld = setup_solo_multiworld(world_type, gen_steps) local_items = multiworld.worlds[1].options.local_items.value.copy() diff --git a/test/general/test_locations.py b/test/general/test_locations.py index 37ae94e003..77ae2602e5 100644 --- a/test/general/test_locations.py +++ b/test/general/test_locations.py @@ -33,7 +33,10 @@ class TestBase(unittest.TestCase): def test_location_creation_steps(self): """Tests that Regions and Locations aren't created after `create_items`.""" gen_steps = ("generate_early", "create_regions", "create_items") - for game_name, world_type in AutoWorldRegister.world_types.items(): + excluded_games = ("Ocarina of Time", "Pokemon Red and Blue") + worlds_to_test = {game: world + for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games} + for game_name, world_type in worlds_to_test.items(): with self.subTest("Game", game_name=game_name): multiworld = setup_solo_multiworld(world_type, gen_steps) region_count = len(multiworld.get_regions()) @@ -54,13 +57,13 @@ class TestBase(unittest.TestCase): call_all(multiworld, "generate_basic") self.assertEqual(region_count, len(multiworld.get_regions()), f"{game_name} modified region count during generate_basic") - self.assertGreaterEqual(location_count, len(multiworld.get_locations()), + self.assertEqual(location_count, len(multiworld.get_locations()), f"{game_name} modified locations count during generate_basic") call_all(multiworld, "pre_fill") self.assertEqual(region_count, len(multiworld.get_regions()), f"{game_name} modified region count during pre_fill") - self.assertGreaterEqual(location_count, len(multiworld.get_locations()), + self.assertEqual(location_count, len(multiworld.get_locations()), f"{game_name} modified locations count during pre_fill") def test_location_group(self): From d5bdac02b76e4d579f7f6cada09132fd6eb833e6 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sun, 24 Aug 2025 02:54:49 +0200 Subject: [PATCH 26/34] Docs: Add deprioritized to AP API doc (#5355) Did this on my phone while in the bathroom :) --- docs/world api.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/world api.md b/docs/world api.md index e8932cfd83..832ad05d4f 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -257,6 +257,14 @@ another flag like "progression", it means "an especially useful progression item combined with `progression`; see below) * `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres +* `deprioritized`: denotes that an item should not be placed on priority locations + (to be combined with `progression`; see below) +* `progression_deprioritized`: the combination of `progression` and `deprioritized`, i.e. a progression item that + should not be placed on priority locations, despite being progression; + like skip_balancing, this is commonly used for currency or tokens. +* `progression_deprioritized_skip_balancing`: the combination of `progression`, `deprioritized` and `skip_balancing`. + Since there is overlap between the kind of items that want `skip_balancing` and `deprioritized`, + this combined classification exists for convenience ### Regions From d146d90131a5087698cbf67a9fc085ce3da2f362 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:52:04 +0200 Subject: [PATCH 27/34] Core: Fix Priority Fill *not* crashing when it should, in cases where there is no deprioritized progression #5363 --- Fill.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Fill.py b/Fill.py index 1cc1278f4b..7a079fbc82 100644 --- a/Fill.py +++ b/Fill.py @@ -549,10 +549,12 @@ def distribute_items_restrictive(multiworld: MultiWorld, if prioritylocations and regular_progression: # retry with one_item_per_player off because some priority fills can fail to fill with that optimization # deprioritized items are still not in the mix, so they need to be collected into state first. + # allow_partial should only be set if there is deprioritized progression to fall back on. priority_retry_state = sweep_from_pool(multiworld.state, deprioritized_progression) fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression, single_player_placement=single_player, swap=False, on_place=mark_for_locking, - name="Priority Retry", one_item_per_player=False, allow_partial=True) + name="Priority Retry", one_item_per_player=False, + allow_partial=bool(deprioritized_progression)) if prioritylocations and deprioritized_progression: # There are no more regular progression items that can be placed on any priority locations. From 1fa342b0859439ed671ad43f1c29ad5fa8468e12 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:36:39 +0000 Subject: [PATCH 28/34] Core: add python 3.13 support (#5357) * Core: fix freeze support for py3.13+ Loading Utils now patches multiprocessing.freeze_support() Utils.freeze_support() is now deprecated * WebHost: use pony fork on py3.13 * CI: test with py3.13 --- .github/workflows/unittests.yml | 7 ++++--- Launcher.py | 2 +- Utils.py | 20 +++++++++++++------- WebHostLib/requirements.txt | 3 ++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 96219daa19..90a5d70b8e 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -41,12 +41,13 @@ jobs: python: - {version: '3.11.2'} # Change to '3.11' around 2026-06-10 - {version: '3.12'} + - {version: '3.13'} include: - python: {version: '3.11'} # old compat os: windows-latest - - python: {version: '3.12'} # current + - python: {version: '3.13'} # current os: windows-latest - - python: {version: '3.12'} # current + - python: {version: '3.13'} # current os: macos-latest steps: @@ -74,7 +75,7 @@ jobs: os: - ubuntu-latest python: - - {version: '3.12'} # current + - {version: '3.13'} # current steps: - uses: actions/checkout@v4 diff --git a/Launcher.py b/Launcher.py index 5720012cf9..adc3cb96ef 100644 --- a/Launcher.py +++ b/Launcher.py @@ -484,7 +484,7 @@ def main(args: argparse.Namespace | dict | None = None): if __name__ == '__main__': init_logging('Launcher') - Utils.freeze_support() + multiprocessing.freeze_support() multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work parser = argparse.ArgumentParser( description='Archipelago Launcher', diff --git a/Utils.py b/Utils.py index 5a24bc157e..e73edd7137 100644 --- a/Utils.py +++ b/Utils.py @@ -940,15 +940,15 @@ class DeprecateDict(dict): def _extend_freeze_support() -> None: - """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" - # upstream issue: https://github.com/python/cpython/issues/76327 + """Extend multiprocessing.freeze_support() to also work on Non-Windows and without setting spawn method first.""" + # original upstream issue: https://github.com/python/cpython/issues/76327 # code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26 import multiprocessing import multiprocessing.spawn def _freeze_support() -> None: """Minimal freeze_support. Only apply this if frozen.""" - from subprocess import _args_from_interpreter_flags + from subprocess import _args_from_interpreter_flags # noqa # Prevent `spawn` from trying to read `__main__` in from the main script multiprocessing.process.ORIGINAL_DIR = None @@ -975,17 +975,23 @@ def _extend_freeze_support() -> None: multiprocessing.spawn.spawn_main(**kwargs) sys.exit() - if not is_windows and is_frozen(): - multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support + def _noop() -> None: + pass + + multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop def freeze_support() -> None: - """This behaves like multiprocessing.freeze_support but also works on Non-Windows.""" + """This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load.""" import multiprocessing - _extend_freeze_support() + + deprecate("Use multiprocessing.freeze_support() instead") multiprocessing.freeze_support() +_extend_freeze_support() + + def visualize_regions(root_region: Region, file_name: str, *, show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None: diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 8fd6dc6304..f64ed085c9 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,6 +1,7 @@ flask>=3.1.1 werkzeug>=3.1.3 -pony>=0.7.19 +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 From e1fca86cf82f0616e292dd5b0375d43fa2e61a48 Mon Sep 17 00:00:00 2001 From: Ishigh1 Date: Wed, 27 Aug 2025 02:36:47 +0200 Subject: [PATCH 29/34] Core: Improved GER's caching of visited nodes during initialization (#5366) * Moved the visited update * Renamed visited to seen --- entrance_rando.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/entrance_rando.py b/entrance_rando.py index 492fff32e3..578059ae6f 100644 --- a/entrance_rando.py +++ b/entrance_rando.py @@ -74,13 +74,12 @@ class EntranceLookup: if entrance in self._expands_graph_cache: return self._expands_graph_cache[entrance] - visited = set() + seen = {entrance.connected_region} q: deque[Region] = deque() q.append(entrance.connected_region) while q: region = q.popleft() - visited.add(region) # check if the region itself is progression if region in region.multiworld.indirect_connections: @@ -103,7 +102,8 @@ class EntranceLookup: and exit_ in self._usable_exits): self._expands_graph_cache[entrance] = True return True - elif exit_.connected_region and exit_.connected_region not in visited: + elif exit_.connected_region and exit_.connected_region not in seen: + seen.add(exit_.connected_region) q.append(exit_.connected_region) self._expands_graph_cache[entrance] = False From be51fb9ba9309bd0dcd20c8da7420af7b7959295 Mon Sep 17 00:00:00 2001 From: Rosalie <61372066+Rosalie-A@users.noreply.github.com> Date: Wed, 27 Aug 2025 09:20:51 -0400 Subject: [PATCH 30/34] [TLOZ] Updated to remove deprecated call. (#5266) * Updated to remove deprecated call. * Removed unused argument. --- worlds/tloz/Rom.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/worlds/tloz/Rom.py b/worlds/tloz/Rom.py index 58aa38805f..e7b60b90fe 100644 --- a/worlds/tloz/Rom.py +++ b/worlds/tloz/Rom.py @@ -70,10 +70,6 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: return base_rom_bytes -def get_base_rom_path(file_name: str = "") -> str: - options = Utils.get_options() - if not file_name: - file_name = options["tloz_options"]["rom_file"] - if not os.path.exists(file_name): - file_name = Utils.user_path(file_name) - return file_name +def get_base_rom_path() -> str: + from . import TLoZWorld + return TLoZWorld.settings.rom_file From e11b40c94b0b570b1ccb01245b6638a01e709658 Mon Sep 17 00:00:00 2001 From: lordlou <87331798+lordlou@users.noreply.github.com> Date: Wed, 27 Aug 2025 09:21:28 -0400 Subject: [PATCH 31/34] [SM, SMZ3] get options deprecation (#5257) * - SM now displays message when getting an item outside for someone else (fills ROM item table) This is dependant on modifications done to sm_randomizer_rom project * First working MultiWorld SM * some missing things: - player name inject in ROM and get in client - end game get from ROM in client - send self item to server - add player names table in ROM * replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better) * - reenabled balancing * post rebase fixes * updated SmClient.py * + added VariaRandomizer LICENSE * + added sm_randomizer_rom project (which builds sm.ips) * Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning * properly revert change made to CollectionState and more cleaning * Fixed multiworld support patch not working with VariaRandomizer's * missing file commit * Fixed syntax error in unused code to satisfy Linter * Revert "Fixed multiworld support patch not working with VariaRandomizer's" This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b. * many fixes and improovement - fixed seeded generation - fixed broken logic when more than one SM world - added missing rules for inter-area transitions - added basic patch presence for logic - added DoorManager init call to reflect present patches for logic - moved CollectionState addition out of BaseClasses into SM world - added condition to apply progitempool presorting only if SM world is present - set Bosses item id to None to prevent them going into multidata - now use get_game_players * first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions) * first working single-world randomized SM rom patches * - SM now displays message when getting an item outside for someone else (fills ROM item table) This is dependant on modifications done to sm_randomizer_rom project * First working MultiWorld SM * some missing things: - player name inject in ROM and get in client - end game get from ROM in client - send self item to server - add player names table in ROM * replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better) * - reenabled balancing * post rebase fixes * updated SmClient.py * + added VariaRandomizer LICENSE * + added sm_randomizer_rom project (which builds sm.ips) * Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning * properly revert change made to CollectionState and more cleaning * Fixed multiworld support patch not working with VariaRandomizer's * missing file commit * Fixed syntax error in unused code to satisfy Linter * Revert "Fixed multiworld support patch not working with VariaRandomizer's" This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b. * many fixes and improovement - fixed seeded generation - fixed broken logic when more than one SM world - added missing rules for inter-area transitions - added basic patch presence for logic - added DoorManager init call to reflect present patches for logic - moved CollectionState addition out of BaseClasses into SM world - added condition to apply progitempool presorting only if SM world is present - set Bosses item id to None to prevent them going into multidata - now use get_game_players * Fixed multiworld support patch not working with VariaRandomizer's Added stage_fill_hook to set morph first in progitempool Added back VariaRandomizer's standard patches * + added missing files from variaRandomizer project * + added missing variaRandomizer files (custom sprites) + started integrating VariaRandomizer options (WIP) * Some fixes for player and server name display - fixed player name of 16 characters reading too far in SM client - fixed 12 bytes SM player name limit (now 16) - fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO) - request: temporarly changed default seed names displayed in SM main menu to OWTCH * Fixed Goal completion not triggering in smClient * integrated VariaRandomizer's options into AP (WIP) - startAP is working - door rando is working - skillset is working * - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off") * skillset are now instanced per player instead of being a singleton class * RomPatches are now instanced per player instead of being a singleton class * DoorManager is now instanced per player instead of being a singleton class * - fixed the last bugs that prevented generation of >1 SM world * fixed crash when no skillset preset is specified in randoPreset (default to "casual") * maxDifficulty support and itemsounds removal - added support for maxDifficulty - removed itemsounds patch as its always applied from multiworld patch for now * Fixed bad merge * Post merge adaptation * fixed player name length fix that got lost with the merge * fixed generation with other game type than SM * added default randoPreset json for SM in playerSettings.yaml * fixed broken SM client following merge * beautified json skillset presets * Fixed ArchipelagoSmClient not building * Fixed conflict between mutliworld patch and beam_doors_plms patch - doorsColorsRando now working * SM generation now outputs APBP - Fixed paths for patches and presets when frozen * added missing file and fixed multithreading issue * temporarily set data_version = 0 * more work - added support for AP starting items - fixed client crash with gamemode being None - patch.py "compatible_version" is now 3 * commited missing asm files fixed start item reserve breaking game (was using bad write offset when patching) * Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it). fixed crash in SMClient when loosing connection to SNI * fixed No Energy Item missing its ID fixed Plando * merge post fixes * fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color) * fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses) * fixed start item x-ray HUD display * Fixed start items being sent by the server (is all handled in ROM) Start items are now not removed from itempool anymore Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though. Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified * fixed settings that could be applied to any SM players * fixed auth to server only using player name (now does as ALTTP to authenticate) * - fixed End Credits broken text * added non SM item name display * added all supported SM options in playerSettings.yaml * fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region did some cleaning (mainly reverts on unnecessary core classes * minor setting fixes and tweaks - merged Area and lightArea settings - made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating - fixed inverted layoutPatch setting * added option start_inventory_removes_from_pool fixed option names formatting fixed lint errors small code and repo cleanup * Hopefully fixed ROR2 that could not send any items * - fixed missing required change to ROR2 * fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum) * fixed typo with doors_colors_rando * fixed checksum * added custom sprites for off-world items (progression or not) the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu * - added missing change following upstream merge - changed patch filename extension from apbp to apm3 so patch can be used with the new client * added morph placement options: early means local and sphere 1 * fixed failing unit tests * - fixed broken custom_preset options * - big cleanup to remove unnecessary or unsupported features * - more cleanup * - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips - small cleanup * - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch) * fixed g4_skip patch that can be not applied if hud is enabled * - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette) * - updated basepatch to reflect g4_skip removal - moved more asm files to SMBasepatch project * - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed) * fixed wrong path if using built as exe * - cleaned exposed maxDifficulty options - removed always enabled Knows * Merged LttPClient and SMClient into SNIClient * added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service * small doc precision * - added death_link support - fixed broken Goal Completion - post merge fix * - removed now useless presets * - fixed bad internal mapping with maxDiff - increases maxDiff if only Bosses is preventing beating the game * - added support for lowercase custom preset sections (knows, settings and controller) - fixed controller settings not applying to ROM * - fixed death loop when dying with Door rando, bomb or speed booster as starting items - varia's backup save should now be usable (automatically enabled when doing door rando) * -added docstring for generated yaml * fixed bad merge * fixed broken infinity max difficulty * commented debug prints * adjusted credits to mark progression speed and difficulty as Non Available * added support for more than 255 players (will print Archipelago for higher player number) * fixed missing cleanup * added support for 65535 different player names in ROM * fixed generations failing when only bosses are unreachable * - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish * fixed failling generations when using 'fun' settings Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings * fixed debug logger * removed unsupported "suits_restriction" option * fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP) * - fixed deathlink emptying reserves - added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves * - merged death_link and death_link_survive options * fixed death_link * added a fallback default starting location instead of failing generation if an invalid one was chosen * added Nothing and NoEnergy as hint blacklist added missing NoEnergy as local items and removed it from progression * replaced deprecated usage of Utils.get_options with settings.get_settings in SM and SMZ3 --- worlds/sm/Rom.py | 3 ++- worlds/smz3/Rom.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/worlds/sm/Rom.py b/worlds/sm/Rom.py index c5b6645ed8..9d567aa482 100644 --- a/worlds/sm/Rom.py +++ b/worlds/sm/Rom.py @@ -1,6 +1,7 @@ import hashlib import os +import settings import json import Utils from Utils import read_snes_rom @@ -77,7 +78,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: def get_base_rom_path(file_name: str = "") -> str: - options = Utils.get_options() + options: settings.Settings = settings.get_settings() if not file_name: file_name = options["sm_options"]["rom_file"] if not os.path.exists(file_name): diff --git a/worlds/smz3/Rom.py b/worlds/smz3/Rom.py index d66d923979..4c66b0d450 100644 --- a/worlds/smz3/Rom.py +++ b/worlds/smz3/Rom.py @@ -1,6 +1,7 @@ import hashlib import os +import settings import Utils from Utils import read_snes_rom from worlds.Files import APProcedurePatch, APPatchExtension, APTokenMixin, APTokenTypes @@ -65,7 +66,7 @@ def get_base_rom_bytes() -> bytes: def get_sm_base_rom_path(file_name: str = "") -> str: - options = Utils.get_options() + options: settings.Settings = settings.get_settings() if not file_name: file_name = options["sm_options"]["rom_file"] if not os.path.exists(file_name): @@ -74,7 +75,7 @@ def get_sm_base_rom_path(file_name: str = "") -> str: def get_lttp_base_rom_path(file_name: str = "") -> str: - options = Utils.get_options() + options: settings.Settings = settings.get_settings() if not file_name: file_name = options["lttp_options"]["rom_file"] if not os.path.exists(file_name): From 750c8a98100ceaea01871255f6da813d0fd839e7 Mon Sep 17 00:00:00 2001 From: PoryGone <98504756+PoryGone@users.noreply.github.com> Date: Wed, 27 Aug 2025 09:21:53 -0400 Subject: [PATCH 32/34] Stop using get_options (#5341) --- worlds/dkc3/Rom.py | 5 +++-- worlds/smw/Regions.py | 2 +- worlds/smw/Rom.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/worlds/dkc3/Rom.py b/worlds/dkc3/Rom.py index fb8bc2b122..cde4d8c764 100644 --- a/worlds/dkc3/Rom.py +++ b/worlds/dkc3/Rom.py @@ -1,3 +1,4 @@ + import Utils from Utils import read_snes_rom from worlds.AutoWorld import World @@ -735,9 +736,9 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: return base_rom_bytes def get_base_rom_path(file_name: str = "") -> str: - options = Utils.get_options() if not file_name: - file_name = options["dkc3_options"]["rom_file"] + from settings import get_settings + file_name = get_settings()["dkc3_options"]["rom_file"] if not os.path.exists(file_name): file_name = Utils.user_path(file_name) return file_name diff --git a/worlds/smw/Regions.py b/worlds/smw/Regions.py index 2496049874..d7950ffb35 100644 --- a/worlds/smw/Regions.py +++ b/worlds/smw/Regions.py @@ -808,7 +808,7 @@ def create_regions(world: World, active_locations): lambda state: (state.has(ItemName.blue_switch_palace, player) and (state.has(ItemName.p_switch, player) or state.has(ItemName.green_switch_palace, player) or - (state.has(ItemName.yellow_switch_palace, player) or state.has(ItemName.red_switch_palace, player))))) + (state.has(ItemName.yellow_switch_palace, player) and state.has(ItemName.red_switch_palace, player))))) add_location_to_region(multiworld, player, active_locations, LocationName.chocolate_island_3_region, LocationName.chocolate_island_3_dragon) add_location_to_region(multiworld, player, active_locations, LocationName.chocolate_island_4_region, LocationName.chocolate_island_4_dragon, lambda state: (state.has(ItemName.p_switch, player) and diff --git a/worlds/smw/Rom.py b/worlds/smw/Rom.py index 9016e14def..081d6b4a50 100644 --- a/worlds/smw/Rom.py +++ b/worlds/smw/Rom.py @@ -3185,9 +3185,9 @@ def get_base_rom_bytes(file_name: str = "") -> bytes: def get_base_rom_path(file_name: str = "") -> str: - options = Utils.get_options() if not file_name: - file_name = options["smw_options"]["rom_file"] + from settings import get_settings + file_name = get_settings()["smw_options"]["rom_file"] if not os.path.exists(file_name): file_name = Utils.user_path(file_name) return file_name From 439be48f36a2ed26bedede7e620d441cc3478b4f Mon Sep 17 00:00:00 2001 From: Rosalie <61372066+Rosalie-A@users.noreply.github.com> Date: Wed, 27 Aug 2025 13:28:42 -0400 Subject: [PATCH 33/34] [TLOZ] Remove deprecated Utils.get_options call, part 2 (#5371) * Updated to remove deprecated call. * Removed unused argument. * Removed errant client calls to Utils.get_options, and fixed call in Rom.py that was passing an argument. --- Zelda1Client.py | 7 ++++--- worlds/tloz/Rom.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Zelda1Client.py b/Zelda1Client.py index 9753621ef0..6dd7a36165 100644 --- a/Zelda1Client.py +++ b/Zelda1Client.py @@ -20,6 +20,8 @@ from worlds.tloz.Items import item_game_ids from worlds.tloz.Locations import location_ids from worlds.tloz import Items, Locations, Rom +from settings import get_settings + SYSTEM_MESSAGE_ID = 0 CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua" @@ -341,13 +343,12 @@ if __name__ == '__main__': # Text Mode to use !hint and such with games that have no text entry Utils.init_logging("ZeldaClient") - options = Utils.get_options() - DISPLAY_MSGS = options["tloz_options"]["display_msgs"] + DISPLAY_MSGS = get_settings()["tloz_options"]["display_msgs"] async def run_game(romfile: str) -> None: auto_start = typing.cast(typing.Union[bool, str], - Utils.get_options()["tloz_options"].get("rom_start", True)) + get_settings()["tloz_options"].get("rom_start", True)) if auto_start is True: import webbrowser webbrowser.open(romfile) diff --git a/worlds/tloz/Rom.py b/worlds/tloz/Rom.py index e7b60b90fe..5b618bb688 100644 --- a/worlds/tloz/Rom.py +++ b/worlds/tloz/Rom.py @@ -58,7 +58,7 @@ class TLoZDeltaPatch(APDeltaPatch): def get_base_rom_bytes(file_name: str = "") -> bytes: base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None) if not base_rom_bytes: - file_name = get_base_rom_path(file_name) + file_name = get_base_rom_path() base_rom_bytes = bytes(Utils.read_snes_rom(open(file_name, "rb"))) basemd5 = hashlib.md5() From bb2ecb8a97913399c64ea1594dadeaadc83c7b9d Mon Sep 17 00:00:00 2001 From: Justus Lind Date: Sat, 30 Aug 2025 01:41:29 +1000 Subject: [PATCH 34/34] Muse Dash: Change Exception to Option Error and Update to Muse Radio FM106 (#5374) * Change Exception to OptionError * Update to Muse Radio FM106. * Add Scipio's suggestion. Co-authored-by: Scipio Wright --------- Co-authored-by: Scipio Wright --- worlds/musedash/MuseDashData.py | 4 ++++ worlds/musedash/__init__.py | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/worlds/musedash/MuseDashData.py b/worlds/musedash/MuseDashData.py index 32849eec9b..6943b281f1 100644 --- a/worlds/musedash/MuseDashData.py +++ b/worlds/musedash/MuseDashData.py @@ -661,4 +661,8 @@ SONG_DATA: Dict[str, SongData] = { "Ineffabilis": SongData(2900785, "87-4", "Aim to Be a Rhythm Master!", False, 3, 7, 10), "DaJiaHao": SongData(2900786, "87-5", "Aim to Be a Rhythm Master!", False, 5, 7, 10), "Echoes of SeraphiM": SongData(2900787, "87-6", "Aim to Be a Rhythm Master!", False, 5, 8, 10), + "Othello feat.Uiro": SongData(2900788, "88-0", "MUSE RADIO FM106", True, 3, 5, 7), + "Midnight Blue": SongData(2900789, "88-1", "MUSE RADIO FM106", True, 2, 5, 7), + "overwork feat.Woonoo": SongData(2900790, "88-2", "MUSE RADIO FM106", True, 2, 6, 8), + "SUPER CITYLIGHTS": SongData(2900791, "88-3", "MUSE RADIO FM106", True, 5, 7, 10), } diff --git a/worlds/musedash/__init__.py b/worlds/musedash/__init__.py index eb82148c1b..239d640e68 100644 --- a/worlds/musedash/__init__.py +++ b/worlds/musedash/__init__.py @@ -2,7 +2,7 @@ from worlds.AutoWorld import World, WebWorld from BaseClasses import Region, Item, ItemClassification, Tutorial from typing import List, ClassVar, Type, Set from math import floor -from Options import PerGameCommonOptions +from Options import PerGameCommonOptions, OptionError from .Options import MuseDashOptions, md_option_groups from .Items import MuseDashSongItem, MuseDashFixedItem @@ -102,7 +102,8 @@ class MuseDashWorld(World): # If the above fails, we want to adjust the difficulty thresholds. # Easier first, then harder if lower_diff_threshold <= 1 and higher_diff_threshold >= 11: - raise Exception("Failed to find enough songs, even with maximum difficulty thresholds.") + raise OptionError("Failed to find enough songs, even with maximum difficulty thresholds. " + "Too many songs have been excluded or set to be starter songs.") elif lower_diff_threshold <= 1: higher_diff_threshold += 1 else: