From 5390561b589283de31c4a80ac4b7c34dc72ee46b Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:46:16 +0000 Subject: [PATCH 01/33] MultiServer: Fix breaking weakrefs for SetNotify (#5539) --- MultiServer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/MultiServer.py b/MultiServer.py index a96131d58ec7..095cb36b5b27 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -135,6 +135,7 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int: class Client(Endpoint): __slots__ = ( + "__weakref__", "version", "auth", "team", From 0c1ecf72971c158315b65ada38c24fb67977d707 Mon Sep 17 00:00:00 2001 From: Seldom <38388947+Seldom-SE@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:06:25 -0700 Subject: [PATCH 02/33] Terraria: Remove `/apstart` from docs (#5537) --- worlds/terraria/docs/setup_en.md | 1 - 1 file changed, 1 deletion(-) diff --git a/worlds/terraria/docs/setup_en.md b/worlds/terraria/docs/setup_en.md index b41595533743..69f531901957 100644 --- a/worlds/terraria/docs/setup_en.md +++ b/worlds/terraria/docs/setup_en.md @@ -50,7 +50,6 @@ on the Archipelago website to generate a YAML using a graphical interface. significantly more difficult with this mod, so it is recommended to choose a lower difficulty than you normally would play on. 4. Open the world in single player or multiplayer. -5. When you're ready, open chat, and enter `/apstart` to start the game. ## Commands From 30cedb13f36321719c7a27fc891f1b400083a65c Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:32:53 -0400 Subject: [PATCH 03/33] Core: Limit ItemLink Name to 16 Characters (#4318) --- Options.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Options.py b/Options.py index 826e45a52ae0..d4e42fc02d8c 100644 --- a/Options.py +++ b/Options.py @@ -1474,8 +1474,10 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() for link in self.value: + link["name"] = link["name"].strip()[:16].strip() if link["name"] in existing_links: - raise Exception(f"You cannot have more than one link named {link['name']}.") + raise Exception(f"Item link names are limited to their first 16 characters and must be unique. " + f"You have more than one link named '{link['name']}'.") existing_links.add(link["name"]) pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) From aff98a5b78cd9a83150e537b3aa3b85add736492 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:55:44 +0200 Subject: [PATCH 04/33] CommonClient: Fix manually connecting to a url when the username or password has a space in it (#5528) * CommonClient: Fix manually connecting to a url when the username or password has a space in it * Update CommonClient.py * Update CommonClient.py --- CommonClient.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index b1d9aeb15624..41cc08d1d0a9 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -856,9 +856,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) server_url = urllib.parse.urlparse(address) if server_url.username: - ctx.username = server_url.username + ctx.username = urllib.parse.unquote(server_url.username) if server_url.password: - ctx.password = server_url.password + ctx.password = urllib.parse.unquote(server_url.password) def reconnect_hint() -> str: return ", type /connect to reconnect" if ctx.server_address else "" From 5ce71db048d4d70b96cce204a3c9d5b044795b7f Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Mon, 13 Oct 2025 13:32:49 -0400 Subject: [PATCH 05/33] LADX: use start_inventory_from_pool (#4641) --- worlds/ladx/Options.py | 3 +- worlds/ladx/__init__.py | 82 +++++++++++++++++++---------------------- 2 files changed, 40 insertions(+), 45 deletions(-) diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index 2352e0fb91bb..9c532c98082a 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -3,7 +3,7 @@ import os.path import typing import logging -from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed +from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed, StartInventoryPool from collections import defaultdict import Utils @@ -665,6 +665,7 @@ class LinksAwakeningOptions(PerGameCommonOptions): tarins_gift: TarinsGift overworld: Overworld stabilize_item_pool: StabilizeItemPool + start_inventory_from_pool: StartInventoryPool warp_improvements: Removed additional_warp_points: Removed diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index a00076a6f793..e5b3d6cc19ca 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -225,8 +225,6 @@ def create_event(self, event: str): def create_items(self) -> None: itempool = [] - exclude = [item.name for item in self.multiworld.precollected_items[self.player]] - self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ] self.prefill_own_dungeons = [] self.pre_fill_items = [] @@ -243,50 +241,46 @@ def create_items(self) -> None: continue item_name = ladxr_item_to_la_item_name[ladx_item_name] for _ in range(count): - if item_name in exclude: - exclude.remove(item_name) # this is destructive. create unique list above - self.multiworld.itempool.append(self.create_item(self.get_filler_item_name())) - else: - item = self.create_item(item_name) - - if not self.options.tradequest and isinstance(item.item_data, TradeItemData): - location = self.multiworld.get_location(item.item_data.vanilla_location, self.player) - location.place_locked_item(item) - location.show_in_spoiler = False - continue - - if isinstance(item.item_data, DungeonItemData): - item_type = item.item_data.ladxr_id[:-1] - shuffle_type = self.dungeon_item_types[item_type] - - if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla: - # Find instrument, lock - # TODO: we should be able to pinpoint the region we want, save a lookup table please - found = False - for r in self.multiworld.get_regions(self.player): - if r.dungeon_index != item.item_data.dungeon_index: + item = self.create_item(item_name) + + if not self.options.tradequest and isinstance(item.item_data, TradeItemData): + location = self.multiworld.get_location(item.item_data.vanilla_location, self.player) + location.place_locked_item(item) + location.show_in_spoiler = False + continue + + if isinstance(item.item_data, DungeonItemData): + item_type = item.item_data.ladxr_id[:-1] + shuffle_type = self.dungeon_item_types[item_type] + + if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla: + # Find instrument, lock + # TODO: we should be able to pinpoint the region we want, save a lookup table please + found = False + for r in self.multiworld.get_regions(self.player): + if r.dungeon_index != item.item_data.dungeon_index: + continue + for loc in r.locations: + if not isinstance(loc, LinksAwakeningLocation): continue - for loc in r.locations: - if not isinstance(loc, LinksAwakeningLocation): - continue - if not isinstance(loc.ladxr_item, Instrument): - continue - loc.place_locked_item(item) - found = True - break - if found: - break - else: - if shuffle_type == DungeonItemShuffle.option_original_dungeon: - self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item) - self.pre_fill_items.append(item) - elif shuffle_type == DungeonItemShuffle.option_own_dungeons: - self.prefill_own_dungeons.append(item) - self.pre_fill_items.append(item) - else: - itempool.append(item) + if not isinstance(loc.ladxr_item, Instrument): + continue + loc.place_locked_item(item) + found = True + break + if found: + break else: - itempool.append(item) + if shuffle_type == DungeonItemShuffle.option_original_dungeon: + self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item) + self.pre_fill_items.append(item) + elif shuffle_type == DungeonItemShuffle.option_own_dungeons: + self.prefill_own_dungeons.append(item) + self.pre_fill_items.append(item) + else: + itempool.append(item) + else: + itempool.append(item) self.multi_key = self.generate_multi_key() From fc404d0cf7c55a1878e25ddf1ad77653723ea396 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Tue, 14 Oct 2025 02:27:41 -0500 Subject: [PATCH 06/33] =?UTF-8?q?MM2:=20fix=20Heat=20Man=20always=20being?= =?UTF-8?q?=20invulnerable=20to=20Atomic=20Fire=C2=A0#5546?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- worlds/mm2/archipelago.json | 2 +- worlds/mm2/data/mm2_basepatch.bsdiff4 | Bin 1440 -> 1443 bytes worlds/mm2/rom.py | 2 -- worlds/mm2/src/mm2_basepatch.asm | 4 ++++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/worlds/mm2/archipelago.json b/worlds/mm2/archipelago.json index 75c098fdf964..9144dc292492 100644 --- a/worlds/mm2/archipelago.json +++ b/worlds/mm2/archipelago.json @@ -1,5 +1,5 @@ { "game": "Mega Man 2", - "world_version": "0.3.2", + "world_version": "0.3.3", "minimum_ap_version": "0.6.4" } diff --git a/worlds/mm2/data/mm2_basepatch.bsdiff4 b/worlds/mm2/data/mm2_basepatch.bsdiff4 index 8f3c17c3c7af32fa06906208f5d94058d7c2cea8..d593ef49102c8961ce663ad79e07a212d80b937d 100644 GIT binary patch delta 724 zcmV;_0xSKX3!@7WLQ_OZMn*I+r2qf`00000-;oi%1R(bS@{!Xif9|buP(pemOpi!C zL8gEJ00T`7fb|*x2cia>NE#Ys0MVvR0D6X+X`sm2Pf*G~N_v?MsAM!AhyXHZ2AXMz z8U}y^BTR$Q05oao836S$Hl~>x44PtkjWGceN_i=e(drF7Mt}_(007VfL7)Ht00000 z41fRt4KxR&8&amBf0Wt+FbLBlCP2}EO)_L)38qal0{|lg(?HNL2r@D;83v4oGC}Gy zb@vlih43>Bbx>Xq;8E1A6cE@SOi^SC192BZIRIM{g1M4H2oq{5pdzT9a9mtyu0=El z<4xl0+SR#K%1RTO;t#tdp0fTjNgwt=Mz9Jck|fwj5{P69e+`6xm_iSmB!oU-cEFSN zLP$h=$57%C)nt(UfgnjJRs;YWEC`h1!?=*0Fd{A$)3#A5gpdI4{{iJ$(r41u=#aAiJju?sL0imAml+eZh1ziLzvC|j%H z=rIyl&_2!Se|V27%ya_ny3ooZ*kqz&k8x@+h0b~e9`<&4cWr^+7y%Ht3}nzHtId33 zGwAM>_7Est$YP`tbP`EKJLHT41Ylsx1uO(p6q#uU1EB-w1OY@X>7Qhxk7grYzEVKE zOd@st`k(yVnPU9tWR;*<^1RF~WOo{yrEiUhFWG?%f3~n%mAm52@(Mcd!&}sV(Wm@i zsCv;i8oIlfor$v=Mp?pBPQPs#x?L8_apg#zP)+bZ6 z-krBOfm2xNvCoA#CiTF&2fYU~7m$%K?>59vYP27ji{7P>>+^0P?e; G1OEY#OfVV% delta 721 zcmV;?0xtcd3!n=TLQ_OZMn*I+r2qf`00000+>sH!1mXouh>_DNf9EyDNtDs)4^ty1 zjRu+l>NEfV(?cKzfCip~7$C@L=zuZ}GzOX;r>UlcBV{xyru3(xG-5Iu8X5*nAkm;? z$j|}$ki^p<$PEmQ0qQhl28^0$(V!U`3`~I&Ns^fX>J2?cfDHfu0MIlV000000002U z0002fKzczWsD_57e;N}_41)m;7z8xQrb7TAXkrZwF)&OKfif~=$Y{hg!7^aU3$6pA zxSO?Uz|7(l(4M4nA{45G1Vz!Rh-i>N8IrCUvB5T)5!}JhL7_!T{S`jqA;}*VC)j?~ z(1AO(^v-Em<#9SLP`|8`wm-}X8?2DH%!E8)9L5p=hh%~ce}DkcU=W_LAbh|jyuv^W zj_JfQ5USV&`H}>HAsHbglGtR0&E>+m1UO`c#dzV<4+cOal6C91fu?^=m#Pq~$_h^u z7(*AxK#(aG3_z43LNXa~1}T24BBFrhD_~VQBy$Y2`6BNFm)ey{Ro+zFzi3%5E-4n zc*+kcUv|Q=DHx9*XZ=pT@{n5H9wh%F*Y#nm+zmPHe=sl|u*e+^s6nO^S_1{8@q~%< zQ~!~6+54w)5=oMU$A?wYS_&x_sY7Dp;E_xy1oxi#FMN1|`+jLDQ|^`upkUc+QN24} za{{)J(}SM~a8c)hbO(AaCNCi(%4NnP0J+V85@H2DLB*Aj!+&zTRWR5h0=Yor1ad2X z*u|kbDzImLs!hrm)$T+RLP>Lp)?ua|)>po)cK4~@kc6UKBJN113KASZsgVG)qyzr} DhI=lQ diff --git a/worlds/mm2/rom.py b/worlds/mm2/rom.py index e37c5bc2a148..97e33bf3c46d 100644 --- a/worlds/mm2/rom.py +++ b/worlds/mm2/rom.py @@ -327,8 +327,6 @@ def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None: patch.write_byte(0x36089, pool[18]) # Intro patch.write_byte(0x361F1, pool[19]) # Title - - from Utils import __version__ patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', 'utf8')[:21] diff --git a/worlds/mm2/src/mm2_basepatch.asm b/worlds/mm2/src/mm2_basepatch.asm index 00c8500f03df..a43f12bb4d86 100644 --- a/worlds/mm2/src/mm2_basepatch.asm +++ b/worlds/mm2/src/mm2_basepatch.asm @@ -58,6 +58,10 @@ FlashFixTarget1: %org($808D, $0B) FlashFixTarget2: +%org($A65C, $0B) +HeatFix: + CMP #$FF + %org($8015, $0D) ClearRefreshHook: ; if we're already doing a fresh load of the stage select From bdae7cd42c975cb37f88471782b79eb5d62047b2 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 14 Oct 2025 20:44:01 +0200 Subject: [PATCH 07/33] MultiServer: Fix hinting multi-copy items bleeding found status (#5547) * fix hinting multi-copy items bleeding found status * reword --- MultiServer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/MultiServer.py b/MultiServer.py index 095cb36b5b27..1de44caddc9d 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -1200,16 +1200,17 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st found = location_id in ctx.location_checks[team, finding_player] entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") + hint_status = status # Assign again because we're in a for loop if found: - status = HintStatus.HINT_FOUND - elif status is None: + hint_status = HintStatus.HINT_FOUND + elif hint_status is None: if item_flags & ItemClassification.trap: - status = HintStatus.HINT_AVOID + hint_status = HintStatus.HINT_AVOID else: - status = HintStatus.HINT_PRIORITY + hint_status = HintStatus.HINT_PRIORITY hints.append( - Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status) + Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status) ) return hints From 28c7a214dc4ad6e2b376a9afcbbb5fe8dee20e18 Mon Sep 17 00:00:00 2001 From: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> Date: Tue, 14 Oct 2025 19:09:05 -0400 Subject: [PATCH 08/33] Core: Use Better Practices Accessing Manifests (#5543) * Close manifest files * Name explicit encoding --- setup.py | 3 ++- worlds/LauncherComponents.py | 3 ++- worlds/__init__.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index f1d052b81570..1a39857b8e3f 100644 --- a/setup.py +++ b/setup.py @@ -381,7 +381,8 @@ def run(self) -> None: file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] world_directory = self.libfolder / "worlds" / file_name if os.path.isfile(world_directory / "archipelago.json"): - manifest = json.load(open(world_directory / "archipelago.json")) + with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) assert "game" in manifest, ( f"World directory {world_directory} has an archipelago.json manifest file, but it" diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 1bc78be1096b..2ec70832c02d 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -271,7 +271,8 @@ def _build_apworlds(*launch_args: str): file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] world_directory = os.path.join("worlds", file_name) if os.path.isfile(os.path.join(world_directory, "archipelago.json")): - manifest = json.load(open(os.path.join(world_directory, "archipelago.json"))) + with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) assert "game" in manifest, ( f"World directory {world_directory} has an archipelago.json manifest file, but it" diff --git a/worlds/__init__.py b/worlds/__init__.py index b7ceb46a1e6f..72ac818198ca 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -122,7 +122,8 @@ def load(self) -> bool: for dirpath, dirnames, filenames in os.walk(world_source.resolved_path): for file in filenames: if file.endswith("archipelago.json"): - manifest = json.load(open(os.path.join(dirpath, file), "r")) + with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) break if manifest: break From 123acdef2351829a31bac086e05a36a7b580939e Mon Sep 17 00:00:00 2001 From: BadMagic100 Date: Wed, 15 Oct 2025 04:35:00 -0700 Subject: [PATCH 09/33] Docs: warn HK users not to use BepInEx #5550 --- worlds/hk/docs/setup_en.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/worlds/hk/docs/setup_en.md b/worlds/hk/docs/setup_en.md index d49a4002e4b7..6728e38d7394 100644 --- a/worlds/hk/docs/setup_en.md +++ b/worlds/hk/docs/setup_en.md @@ -5,6 +5,8 @@ * A legal copy of Hollow Knight. * Steam, Gog, and Xbox Game Pass versions of the game are supported. * Windows, Mac, and Linux (including Steam Deck) are supported. + +**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods. ## Installing the Archipelago Mod using Lumafly 1. Launch Lumafly and ensure it locates your Hollow Knight installation directory. From f6d696ea62a2099e3f1635ed593ca488bcdc37ec Mon Sep 17 00:00:00 2001 From: JaredWeakStrike <96694163+JaredWeakStrike@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:40:21 -0400 Subject: [PATCH 10/33] KH2: Manifest File (#5553) * manifest file * x y z for world version --- worlds/kh2/archipelago.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 worlds/kh2/archipelago.json diff --git a/worlds/kh2/archipelago.json b/worlds/kh2/archipelago.json new file mode 100644 index 000000000000..71ca906fb35b --- /dev/null +++ b/worlds/kh2/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Kingdom Hearts 2 Final Mix", + "authors": [ "JaredWeakStrike" ], + "minimum_ap_version": "0.6.3", + "world_version": "2.0.0" +} From cf02e1a1aac6d6531e373ecb35a70382ebba7393 Mon Sep 17 00:00:00 2001 From: BlastSlimey <89539656+BlastSlimey@users.noreply.github.com> Date: Wed, 15 Oct 2025 23:41:15 +0200 Subject: [PATCH 11/33] shapez: Fix floating layers logic error #5263 --- worlds/shapez/regions.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/worlds/shapez/regions.py b/worlds/shapez/regions.py index b5835461d875..e4d755a5386c 100644 --- a/worlds/shapez/regions.py +++ b/worlds/shapez/regions.py @@ -101,7 +101,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) -> def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int, - includeuseful: bool) -> bool: + includeuseful: bool, floating: bool) -> bool: # Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and @@ -109,7 +109,7 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: list return False if buildings[index] == ITEMS.cutter: - if buildings.index(ITEMS.stacker) < index: + if buildings.index(ITEMS.stacker) < index and not floating: return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player) else: return can_cut_half(state, player) @@ -195,38 +195,38 @@ def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool, # Progressively connect level and upgrade regions regions[REGIONS.main].connect( regions[REGIONS.levels_1], "Using first level building", - lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False)) + lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False, floating)) regions[REGIONS.levels_1].connect( regions[REGIONS.levels_2], "Using second level building", - lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False)) + lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False, floating)) regions[REGIONS.levels_2].connect( regions[REGIONS.levels_3], "Using third level building", lambda state: has_logic_list_building(state, player, level_logic_buildings, 2, - early_useful == OPTIONS.buildings_3)) + early_useful == OPTIONS.buildings_3, floating)) regions[REGIONS.levels_3].connect( regions[REGIONS.levels_4], "Using fourth level building", - lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False)) + lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False, floating)) regions[REGIONS.levels_4].connect( regions[REGIONS.levels_5], "Using fifth level building", lambda state: has_logic_list_building(state, player, level_logic_buildings, 4, - early_useful == OPTIONS.buildings_5)) + early_useful == OPTIONS.buildings_5, floating)) regions[REGIONS.main].connect( regions[REGIONS.upgrades_1], "Using first upgrade building", - lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False)) + lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False, floating)) regions[REGIONS.upgrades_1].connect( regions[REGIONS.upgrades_2], "Using second upgrade building", - lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False)) + lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False, floating)) regions[REGIONS.upgrades_2].connect( regions[REGIONS.upgrades_3], "Using third upgrade building", lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2, - early_useful == OPTIONS.buildings_3)) + early_useful == OPTIONS.buildings_3, floating)) regions[REGIONS.upgrades_3].connect( regions[REGIONS.upgrades_4], "Using fourth upgrade building", - lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False)) + lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False, floating)) regions[REGIONS.upgrades_4].connect( regions[REGIONS.upgrades_5], "Using fifth upgrade building", lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4, - early_useful == OPTIONS.buildings_5)) + early_useful == OPTIONS.buildings_5, floating)) # Connect Uncolored shapesanity regions to Main regions[REGIONS.main].connect( From 03bd59bff6a01a4eec7f1862319e72904c13deb8 Mon Sep 17 00:00:00 2001 From: RoobyRoo Date: Thu, 16 Oct 2025 03:48:04 -0600 Subject: [PATCH 12/33] Ocarina of Time: Create manifest (#5536) * Create archipelago.json * Sure, let's call it 7.0.0 * Update archipelago.json --------- Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> --- worlds/oot/archipelago.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 worlds/oot/archipelago.json diff --git a/worlds/oot/archipelago.json b/worlds/oot/archipelago.json new file mode 100644 index 000000000000..d651bfeb4715 --- /dev/null +++ b/worlds/oot/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Ocarina of Time", + "authors": ["espeon65536"], + "world_version": "7.0.0", + "minimum_ap_version": "0.6.4" +} From 91439e0fb08e5de99ce4abeaec8577242136811e Mon Sep 17 00:00:00 2001 From: JaredWeakStrike <96694163+JaredWeakStrike@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:25:11 -0400 Subject: [PATCH 13/33] KH2: Manifest eletric boogaloo (#5556) * manifest file * x y z for world version * Update archipelago.json --- worlds/kh2/archipelago.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/kh2/archipelago.json b/worlds/kh2/archipelago.json index 71ca906fb35b..180801e687de 100644 --- a/worlds/kh2/archipelago.json +++ b/worlds/kh2/archipelago.json @@ -1,5 +1,5 @@ { - "game": "Kingdom Hearts 2 Final Mix", + "game": "Kingdom Hearts 2", "authors": [ "JaredWeakStrike" ], "minimum_ap_version": "0.6.3", "world_version": "2.0.0" From 406b905dc89383868ff7337eaeddfef25d23bd39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Bolduc?= <16137441+Jouramie@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:23:23 -0400 Subject: [PATCH 14/33] Stardew Valley: Add archipelago.json (#5535) * add apworld manifest * add world version --- worlds/stardew_valley/archipelago.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 worlds/stardew_valley/archipelago.json diff --git a/worlds/stardew_valley/archipelago.json b/worlds/stardew_valley/archipelago.json new file mode 100644 index 000000000000..e47d48324870 --- /dev/null +++ b/worlds/stardew_valley/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Stardew Valley", + "authors": ["KaitoKid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"], + "minimum_ap_version": "0.6.4", + "world_version": "6.0.0" +} From f756919dd934e233502f8af95fc533fd3812cae6 Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Thu, 16 Oct 2025 15:58:12 -0600 Subject: [PATCH 15/33] CI: Add worlds manifests to build action trigger (#5555) Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7151ff00c88e..f5eb371567be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,12 +9,14 @@ on: - 'setup.py' - 'requirements.txt' - '*.iss' + - 'worlds/*/archipelago.json' pull_request: paths: - '.github/workflows/build.yml' - 'setup.py' - 'requirements.txt' - '*.iss' + - 'worlds/*/archipelago.json' workflow_dispatch: env: From 0718ada6827e14749edfde09b5f16bf34dce5c4d Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:20:34 -0600 Subject: [PATCH 16/33] Core: Allow PlandoItems to be pickled (#5335) * Add Options.PlandoItem * Remove worlds.generic.PlandoItem handling * Add plando pickling test * Revert old PlandoItem cleanup * Deprecate old PlandoItem * Change to warning message * Use deprecated decorator --- Utils.py | 2 +- test/general/test_options.py | 26 +++++++++++++++++++++++--- worlds/generic/__init__.py | 4 +++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Utils.py b/Utils.py index 02bc8e8f6fbd..4fe9c1b43ac9 100644 --- a/Utils.py +++ b/Utils.py @@ -477,7 +477,7 @@ def find_class(self, module: str, name: str) -> type: mod = importlib.import_module(module) obj = getattr(mod, name) if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, - self.options_module.PlandoText)): + self.options_module.PlandoItem, self.options_module.PlandoText)): return obj # Forbid everything else. raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") diff --git a/test/general/test_options.py b/test/general/test_options.py index d8ce7017f27b..f5111e91388d 100644 --- a/test/general/test_options.py +++ b/test/general/test_options.py @@ -1,7 +1,7 @@ import unittest from BaseClasses import PlandoOptions -from Options import ItemLinks, Choice +from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts from Utils import restricted_dumps from worlds.AutoWorld import AutoWorldRegister @@ -72,8 +72,8 @@ def test_item_links_resolve(self): for link in item_links.values(): self.assertEqual(link.value[0], item_link_group[0]) - def test_pickle_dumps(self): - """Test options can be pickled into database for WebHost generation""" + def test_pickle_dumps_default(self): + """Test that default option values can be pickled into database for WebHost generation""" for gamename, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: for option_key, option in world_type.options_dataclass.type_hints.items(): @@ -81,3 +81,23 @@ def test_pickle_dumps(self): restricted_dumps(option.from_any(option.default)) if issubclass(option, Choice) and option.default in option.name_lookup: restricted_dumps(option.from_text(option.name_lookup[option.default])) + + def test_pickle_dumps_plando(self): + """Test that plando options using containers of a custom type can be pickled""" + # The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it + class TestPlandoConnections(PlandoConnections): + entrances = {"An Entrance"} + exits = {"An Exit"} + plando_connection_value = PlandoConnections( + TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}]) + ) + + plando_values = { + "PlandoConnections": plando_connection_value, + "PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]), + "PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]), + } + + for option_key, value in plando_values.items(): + with self.subTest(option=option_key): + restricted_dumps(value) diff --git a/worlds/generic/__init__.py b/worlds/generic/__init__.py index fa53f31f7c1b..2e614eba4ddc 100644 --- a/worlds/generic/__init__.py +++ b/worlds/generic/__init__.py @@ -1,4 +1,5 @@ from typing import NamedTuple, Union +from typing_extensions import deprecated import logging from BaseClasses import Item, Tutorial, ItemClassification @@ -49,7 +50,8 @@ def create_item(self, name: str) -> Item: return Item(name, ItemClassification.filler, -1, self.player) raise InvalidItemError(name) - +@deprecated("worlds.generic.PlandoItem is deprecated and will be removed in the next version. " + "Use Options.PlandoItem(s) instead.") class PlandoItem(NamedTuple): item: str location: str From da519e7f73a811d7b1396a03f028670a6b782d35 Mon Sep 17 00:00:00 2001 From: Snarky Date: Fri, 17 Oct 2025 16:30:05 +0200 Subject: [PATCH 17/33] SC2: fix incorrect preset option (#5551) * SC2: fix incorrect preset option * SC2: fix incorrect evil logic preset option --------- Co-authored-by: Snarky --- worlds/sc2/mission_order/presets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/worlds/sc2/mission_order/presets.py b/worlds/sc2/mission_order/presets.py index 2910ca2433b9..2a3977eb1b5e 100644 --- a/worlds/sc2/mission_order/presets.py +++ b/worlds/sc2/mission_order/presets.py @@ -209,7 +209,7 @@ OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default, OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, - OPTION_NAME[MissionOrder]: MissionOrder.option_blitz, + OPTION_NAME[MissionOrder]: MissionOrder.option_golden_path, OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, @@ -331,12 +331,13 @@ OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, OPTION_NAME[MissionOrder]: MissionOrder.option_grid, - OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard, + OPTION_NAME[RequiredTactics]: RequiredTactics.option_any_units, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced, OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines, OPTION_NAME[MaximumCampaignSize]: 35, + OPTION_NAME[TwoStartPositions]: TwoStartPositions.option_true, OPTION_NAME[StarterUnit]: StarterUnit.option_off, OPTION_NAME[EnableMorphling]: EnableMorphling.option_true, OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false, From 3f2942c599e153693a910a6834809d96201f1a01 Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:32:58 -0400 Subject: [PATCH 18/33] Super Mario Land 2: Logic fixes #5258 Co-authored-by: alchav --- worlds/marioland2/logic.py | 53 ++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/worlds/marioland2/logic.py b/worlds/marioland2/logic.py index 5405e8287739..492ba80a12e4 100644 --- a/worlds/marioland2/logic.py +++ b/worlds/marioland2/logic.py @@ -106,26 +106,38 @@ def tree_zone_4_midway_bell(state, player): def tree_zone_4_coins(state, player, coins): auto_scroll = is_auto_scroll(state, player, "Tree Zone 4") - reachable_coins = 0 + entryway = 14 + hall = 4 + first_trip_downstairs = 31 + second_trip_downstairs = 15 + downstairs_with_auto_scroll = 12 + final_room = 10 + + reachable_coins_from_start = 0 + reachable_coins_from_bell = 0 + if has_pipe_up(state, player): - reachable_coins += 14 + reachable_coins_from_start += entryway if has_pipe_right(state, player): - reachable_coins += 4 + reachable_coins_from_start += hall if has_pipe_down(state, player): - reachable_coins += 10 - if not auto_scroll: - reachable_coins += 46 - elif state.has("Tree Zone 4 Midway Bell", player): - if not auto_scroll: - if has_pipe_left(state, player): - reachable_coins += 18 - if has_pipe_down(state, player): - reachable_coins += 10 + if auto_scroll: + reachable_coins_from_start += downstairs_with_auto_scroll + else: + reachable_coins_from_start += final_room + first_trip_downstairs + second_trip_downstairs + if state.has("Tree Zone 4 Midway Bell", player): + if has_pipe_down(state, player) and (auto_scroll or not has_pipe_left(state, player)): + reachable_coins_from_bell += final_room + elif has_pipe_left(state, player) and not auto_scroll: + if has_pipe_down(state, player): + reachable_coins_from_bell += first_trip_downstairs + if has_pipe_right(state, player): + reachable_coins_from_bell += entryway + hall if has_pipe_up(state, player): - reachable_coins += 46 - elif has_pipe_down(state, player): - reachable_coins += 10 - return coins <= reachable_coins + reachable_coins_from_bell += second_trip_downstairs + final_room + else: + reachable_coins_from_bell += entryway + hall + return coins <= max(reachable_coins_from_start, reachable_coins_from_bell) def tree_zone_5_boss(state, player): @@ -239,12 +251,9 @@ def pumpkin_zone_4_coins(state, player, coins): def mario_zone_1_normal_exit(state, player): - if has_pipe_right(state, player): - if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Mario Zone 1 Midway Bell"], player): - return True - if is_auto_scroll(state, player, "Mario Zone 1"): - return True - return False + return has_pipe_right(state, player) and (not is_auto_scroll(state, player, "Mario Zone 1") + or state.has_any(["Mushroom", "Fire Flower", "Carrot", + "Mario Zone 1 Midway Bell"], player)) def mario_zone_1_midway_bell(state, player): From f5f554cb3dd89a96b44209f5eb0bbd4a0b30d45d Mon Sep 17 00:00:00 2001 From: Rosalie <61372066+Rosalie-A@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:34:10 -0400 Subject: [PATCH 19/33] [FF1] Client fix and improvement (#5390) * FF1 Client fixes. * Strip leading/trailing spaces from rom-stored player name. * FF1R encodes the name as utf-8, as it happens. * UTF-8 is four bytes per character, so we need 64 bytes for the name, not 16. --- worlds/ff1/Client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/worlds/ff1/Client.py b/worlds/ff1/Client.py index a4279afd3a4a..4b21e2ac5e6c 100644 --- a/worlds/ff1/Client.py +++ b/worlds/ff1/Client.py @@ -16,6 +16,7 @@ rom_name_location = 0x07FFE3 +player_name_location = 0x07BCC0 locations_array_start = 0x200 locations_array_length = 0x100 items_obtained = 0x03 @@ -111,6 +112,12 @@ async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: return True + async def set_auth(self, ctx: "BizHawkClientContext") -> None: + auth_raw = (await bizhawk.read( + ctx.bizhawk_ctx, + [(player_name_location, 0x40, self.rom)]))[0] + ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip() + async def game_watcher(self, ctx: "BizHawkClientContext") -> None: if ctx.server is None: return @@ -204,7 +211,7 @@ async def received_items_check(self, ctx: "BizHawkClientContext") -> None: write_list.append((location, [0], self.sram)) elif current_item_name in no_overworld_items: if current_item_name == "Sigil": - location = 0x28 + location = 0x2B else: location = 0x12 write_list.append((location, [1], self.sram)) From 7ead8fdf49572d862a2f309fb0285d5ec645ec00 Mon Sep 17 00:00:00 2001 From: Carter Hesterman Date: Fri, 17 Oct 2025 08:35:44 -0600 Subject: [PATCH 20/33] Civ 6: Add era requirements for boosts and update boost prereqs (#5296) * Resolve #5136 * Resolves #5210 --- worlds/civ_6/ItemData.py | 1 + worlds/civ_6/Locations.py | 5 +- worlds/civ_6/data/boosts.py | 32 ++++++------ worlds/civ_6/test/TestBoostsanity.py | 75 ++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 15 deletions(-) diff --git a/worlds/civ_6/ItemData.py b/worlds/civ_6/ItemData.py index 5f3c16a9b1ba..7ed1d713e30c 100644 --- a/worlds/civ_6/ItemData.py +++ b/worlds/civ_6/ItemData.py @@ -20,6 +20,7 @@ class CivVIBoostData: Prereq: List[str] PrereqRequiredCount: int Classification: str + EraRequired: bool = False class GoodyHutRewardData(TypedDict): diff --git a/worlds/civ_6/Locations.py b/worlds/civ_6/Locations.py index 71f29f1cfc89..7da824dfe8e4 100644 --- a/worlds/civ_6/Locations.py +++ b/worlds/civ_6/Locations.py @@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]: location = CivVILocationData( boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST ) - era_locations["ERA_ANCIENT"][boost.Type] = location + # If EraRequired is True, place the boost in its actual era + # Otherwise, place it in ERA_ANCIENT for early access + target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT" + era_locations[target_era][boost.Type] = location id_base += 1 return era_locations diff --git a/worlds/civ_6/data/boosts.py b/worlds/civ_6/data/boosts.py index 49cedfdfd991..d0bdecc0558c 100644 --- a/worlds/civ_6/data/boosts.py +++ b/worlds/civ_6/data/boosts.py @@ -210,8 +210,8 @@ CivVIBoostData( "BOOST_TECH_SQUARE_RIGGING", "ERA_RENAISSANCE", - ["TECH_GUNPOWDER"], - 1, + ["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -252,15 +252,15 @@ CivVIBoostData( "BOOST_TECH_BALLISTICS", "ERA_INDUSTRIAL", - ["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"], - 2, + ["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"], + 3, "DEFAULT", ), CivVIBoostData( "BOOST_TECH_MILITARY_SCIENCE", "ERA_INDUSTRIAL", - ["TECH_STIRRUPS"], - 1, + ["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -301,8 +301,8 @@ CivVIBoostData( "BOOST_TECH_REPLACEABLE_PARTS", "ERA_MODERN", - ["TECH_MILITARY_SCIENCE"], - 1, + ["TECH_MILITARY_SCIENCE", "TECH_MINING"], + 2, "DEFAULT", ), CivVIBoostData( @@ -343,8 +343,8 @@ CivVIBoostData( "BOOST_TECH_ADVANCED_FLIGHT", "ERA_ATOMIC", - ["TECH_FLIGHT"], - 1, + ["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -436,8 +436,8 @@ CivVIBoostData( "BOOST_TECH_COMPOSITES", "ERA_INFORMATION", - ["TECH_COMBUSTION"], - 1, + ["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -470,7 +470,7 @@ "TECH_ELECTRICITY", "TECH_NUCLEAR_FISSION", ], - 1, + 4, "DEFAULT", ), CivVIBoostData( @@ -651,10 +651,11 @@ ), CivVIBoostData( "BOOST_CIVIC_FEUDALISM", - "ERA_MEDIEVAL", + "ERA_CLASSICAL", [], 0, "DEFAULT", + True, ), CivVIBoostData( "BOOST_CIVIC_CIVIL_SERVICE", @@ -662,6 +663,7 @@ [], 0, "DEFAULT", + True, ), CivVIBoostData( "BOOST_CIVIC_MERCENARIES", @@ -790,6 +792,7 @@ [], 0, "DEFAULT", + True ), CivVIBoostData( "BOOST_CIVIC_CONSERVATION", @@ -885,6 +888,7 @@ ["TECH_ROCKETRY"], 1, "DEFAULT", + True ), CivVIBoostData( "BOOST_CIVIC_GLOBALIZATION", diff --git a/worlds/civ_6/test/TestBoostsanity.py b/worlds/civ_6/test/TestBoostsanity.py index 6efed6c66e25..54ad74cb08ad 100644 --- a/worlds/civ_6/test/TestBoostsanity.py +++ b/worlds/civ_6/test/TestBoostsanity.py @@ -105,3 +105,78 @@ def test_boosts_are_not_included(self) -> None: if "BOOST" in location.name: found_locations += 1 self.assertEqual(found_locations, 0) + + +class TestBoostsanityEraRequired(CivVITestBase): + options = { + "boostsanity": "true", + "progression_style": "none", + "shuffle_goody_hut_rewards": "false", + } + + def test_era_required_boosts_not_accessible_early(self) -> None: + # BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL + # It should NOT be accessible in Ancient era + self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + + # BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL + # It should NOT be accessible in Ancient era + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC + # It should NOT be accessible in Ancient era + self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE")) + + # Regular boosts without EraRequired should be accessible + self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING")) + self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION")) + + def test_era_required_boosts_accessible_in_correct_era(self) -> None: + # Collect items to reach Classical era + self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing", + "Irrigation", "Sailing", "Animal Husbandry", + "State Workforce", "Foreign Trade"]) + + # BOOST_CIVIC_FEUDALISM should now be accessible in Classical era + self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + + # BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial) + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # Collect more items to reach Industrial era + self.collect_all_but(["TECH_ROCKETRY"]) + + # Now BOOST_CIVIC_URBANIZATION should be accessible + self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + +class TestBoostsanityEraRequiredWithProgression(CivVITestBase): + options = { + "boostsanity": "true", + "progression_style": "eras_and_districts", + "shuffle_goody_hut_rewards": "false", + } + + def test_era_required_with_progressive_eras(self) -> None: + # Collect all items except Progressive Era + self.collect_all_but(["Progressive Era"]) + + # Even with all other items, era-required boosts should not be accessible + self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # Collect enough Progressive Era items to reach Classical (needs 2) + self.collect(self.get_item_by_name("Progressive Era")) + self.collect(self.get_item_by_name("Progressive Era")) + + # BOOST_CIVIC_FEUDALISM should now be accessible + self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + + # But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total) + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # Collect 3 more Progressive Era items to reach Industrial + self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"]) + + # Now BOOST_CIVIC_URBANIZATION should be accessible + self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) From 946f22722602bfdbf9a345bc24bbb77bf6a01e9c Mon Sep 17 00:00:00 2001 From: Rosalie <61372066+Rosalie-A@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:44:11 -0400 Subject: [PATCH 21/33] [FF1] Added Deep Dungeon locations to locations.json so they exist in the datapackage (#5392) * Added DD locations to locations.json so they exist in the datapackage. * Update worlds/ff1/data/locations.json Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update worlds/ff1/data/locations.json Forgot trailing commas aren't allowed in JSON. Co-authored-by: qwint --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: qwint --- worlds/ff1/data/locations.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/worlds/ff1/data/locations.json b/worlds/ff1/data/locations.json index 2f465a78970e..a1664d442fbd 100644 --- a/worlds/ff1/data/locations.json +++ b/worlds/ff1/data/locations.json @@ -253,5 +253,17 @@ "CubeBot": 529, "Sarda": 525, "Fairy": 531, - "Lefein": 527 + "Lefein": 527, + "DeepDungeon32B_Chest144": 401, + "DeepDungeon30B_Chest145": 402, + "DeepDungeon29B_Chest146": 403, + "DeepDungeon29B_Chest147": 404, + "DeepDungeon40B_Chest186": 443, + "DeepDungeon38B_Chest188": 445, + "DeepDungeon36B_Chest189": 446, + "DeepDungeon33B_Chest190": 447, + "DeepDungeon40B_Chest191": 448, + "DeepDungeon41B_Chest192": 449, + "DeepDungeon34B_Chest193": 450, + "DeepDungeon39B_Chest194": 451 } From 2569c9e53177accbddb35f58eb14a9c3a6c524bc Mon Sep 17 00:00:00 2001 From: Benny D <78334662+benny-dreamly@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:30:24 -0600 Subject: [PATCH 22/33] DLC Quest: Enable multi-classification items (#5552) * implement prog trap item (thanks stardew) * oops that's wrong * okay this is right --- worlds/dlcquest/Items.py | 3 ++- worlds/dlcquest/data/items.csv | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py index 5496885a74f7..380c54290ec9 100644 --- a/worlds/dlcquest/Items.py +++ b/worlds/dlcquest/Items.py @@ -2,6 +2,7 @@ import enum import math from dataclasses import dataclass, field +from functools import reduce from random import Random from typing import Dict, List, Set @@ -61,7 +62,7 @@ def load_item_csv(): item_reader = csv.DictReader(file) for item in item_reader: id = int(item["id"]) if item["id"] else None - classification = ItemClassification[item["classification"]] + classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")}) groups = {Group[group] for group in item["groups"].split(",") if group} items.append(ItemData(id, item["name"], classification, groups)) return items diff --git a/worlds/dlcquest/data/items.csv b/worlds/dlcquest/data/items.csv index 82150254b3c1..7d9fdcf364a5 100644 --- a/worlds/dlcquest/data/items.csv +++ b/worlds/dlcquest/data/items.csv @@ -22,7 +22,7 @@ id,name,classification,groups 20,Wall Jump Pack,progression,"DLC,Freemium" 21,Health Bar Pack,useful,"DLC,Freemium" 22,Parallax Pack,filler,"DLC,Freemium" -23,Harmless Plants Pack,progression,"DLC,Freemium" +23,Harmless Plants Pack,"progression,trap","DLC,Freemium" 24,Death of Comedy Pack,progression,"DLC,Freemium" 25,Canadian Dialog Pack,filler,"DLC,Freemium" 26,DLC NPC Pack,progression,"DLC,Freemium" From 2ac9ab53371917fff5534accf552173f36de025a Mon Sep 17 00:00:00 2001 From: Fafale <69489522+Fafale@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:36:35 -0300 Subject: [PATCH 23/33] Docs: add warning about BepInEx to HK translated setup guides (#5554) * Update HK pt-br setup to add warning about BepInEx * Update HK spanish setup guide to add warning about BepInEx --- worlds/hk/docs/setup_es.md | 6 +++++- worlds/hk/docs/setup_pt_br.md | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/worlds/hk/docs/setup_es.md b/worlds/hk/docs/setup_es.md index 13628c401905..5a7667665bf1 100644 --- a/worlds/hk/docs/setup_es.md +++ b/worlds/hk/docs/setup_es.md @@ -5,6 +5,10 @@ * Tener una copia legal de Hollow Knight. * Las versiones de Steam, GOG y Xbox Game Pass son compatibles * Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles + +**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods) +usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y +similares, en vez de sitios web como Nexus Mods. ## Instalación del mod de Archipelago con Lumafly 1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight @@ -61,4 +65,4 @@ de Archipelago para generar un YAML usando una interfaz gráfica. ## Consejos y otros comandos Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la [guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto, -que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). \ No newline at end of file +que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). diff --git a/worlds/hk/docs/setup_pt_br.md b/worlds/hk/docs/setup_pt_br.md index 14e54db1315c..6509d27a2c06 100644 --- a/worlds/hk/docs/setup_pt_br.md +++ b/worlds/hk/docs/setup_pt_br.md @@ -5,6 +5,10 @@ * Uma cópia legal de Hollow Knight. * Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas. * Windows, Mac, e Linux (incluindo Steam Deck) são suportados. + +**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods) +usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes, +ao invés de sites como o Nexus Mods. ## Instalando o mod Archipelago Mod usando Lumafly 1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight. From 00acfe63d4e7128589be56c3480deece326f689b Mon Sep 17 00:00:00 2001 From: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:40:25 -0400 Subject: [PATCH 24/33] WebHost: Update publish_parts parameters (#5544) old name is deprecated and new name allows both writer instance or alias/name. --- WebHostLib/options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 2df2e64aeb86..c2f1619f9ff0 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str: lines = text.splitlines() text = lines[0] + "\n" + dedent("\n".join(lines[1:])) - return publish_parts(text, writer_name='html', settings=None, settings_overrides={ + return publish_parts(text, writer='html', settings=None, settings_overrides={ 'raw_enable': False, 'file_insertion_enabled': False, 'output_encoding': 'unicode' From 11d18db4520910bf62a789328f437dcc702094d4 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sun, 19 Oct 2025 09:05:34 +0200 Subject: [PATCH 25/33] Docs: APWorld documentation, make a distinction between APWorld and .apworld (#5509) * APWorld docs: Make a distinction between APWorld and .apworld * Update apworld specification.md * Update apworld specification.md * Be more anal about the launcher component * Update apworld specification.md * Update apworld specification.md --- docs/apworld specification.md | 44 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/apworld specification.md b/docs/apworld specification.md index be0e9cc15021..7022e0ac3df2 100644 --- a/docs/apworld specification.md +++ b/docs/apworld specification.md @@ -1,49 +1,49 @@ -# apworld Specification +# APWorld Specification Archipelago depends on worlds to provide game-specific details like items, locations and output generation. -Those are located in the `worlds/` folder (source) or `/lib/worlds/` (when installed). +These are called "APWorlds". +They are located in the `worlds/` folder (source) or `/lib/worlds/` (when installed). See [world api.md](world%20api.md) for details. +APWorlds can either be a folder, or they can be packaged as an .apworld file. -apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld` -file into the worlds folder. +## .apworld File Format -**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+! +The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution +by placing a `*.apworld` file into the worlds folder. - -## File Format - -apworld files are zip archives, all lower case, with the file ending `.apworld`. +`.apworld` files are zip archives, all lower case, with the file ending `.apworld`. The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. +**Warning:** `.apworld` files have to be all lower case, +otherwise they raise a bogus Exception when trying to import in frozen python 3.10+! ## Metadata -Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive. -The current format version has at minimum: +Metadata about the APWorld is defined in an `archipelago.json` file. + +If the APWorld is a folder, the only required field is "game": ```json { - "version": 7, - "compatible_version": 7, - "game": "Game Name" + "game": "Game Name" } ``` -The `version` and `compatible_version` fields refer to Archipelago's internal file packaging scheme -and get automatically added to the `archipelago.json` of an .apworld if it is packaged using the -["Build apworlds" launcher component](#build-apworlds-launcher-component), -which is the correct way to package your .apworld as a world developer. Do not write these fields yourself. -On the other hand, the `game` field should be present in the world folder's manifest file before packaging. - There are also the following optional fields: * `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current Archipelago version respectively to filter those files from being loaded. * `world_version` - an arbitrary version for that world in order to only load the newest valid world. - An apworld without a world_version is always treated as older than one with a version. + An APWorld without a world_version is always treated as older than one with a version (**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`) * `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and package managers. Should always be a list of strings. +If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`, +which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py). +These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the +["Build apworlds" launcher component](#build-apworlds-launcher-component), +which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself. + ### "Build apworlds" Launcher Component In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`, @@ -86,7 +86,7 @@ The zip can contain arbitrary files in addition what was specified above. ## Caveats -Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions` +Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions` Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or `from worlds.AutoWorld import World` From 914a534a3b11cf2ddb0a25a8023b6207299b34bf Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Mon, 20 Oct 2025 07:16:29 +0000 Subject: [PATCH 26/33] WebHost: fix gen timeout/exception resource handling (#5540) * WebHost: reset Generator proc title on error * WebHost: fix shutting down autogen This is still not perfect but solves some of the issues. * WebHost: properly propagate JOB_TIME * WebHost: handle autogen shutdown --- Utils.py | 38 ++++++++++++++++++++++++++ WebHostLib/autolauncher.py | 39 ++++++++++++++++++--------- WebHostLib/generate.py | 19 +++++++++---- test/utils/test_daemon_thread_pool.py | 14 ++++++++++ 4 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 test/utils/test_daemon_thread_pool.py diff --git a/Utils.py b/Utils.py index 4fe9c1b43ac9..e79e54182d88 100644 --- a/Utils.py +++ b/Utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import concurrent.futures import json import typing import builtins @@ -1138,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any] if isinstance(obj, str): return False return isinstance(obj, typing.Iterable) + + +class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor): + """ + ThreadPoolExecutor that uses daemonic threads that do not keep the program alive. + NOTE: use this with caution because killed threads will not properly clean up. + """ + + def _adjust_thread_count(self): + # see upstream ThreadPoolExecutor for details + import threading + import weakref + from concurrent.futures.thread import _worker + + if self._idle_semaphore.acquire(timeout=0): + return + + def weakref_cb(_, q=self._work_queue): + q.put(None) + + num_threads = len(self._threads) + if num_threads < self._max_workers: + thread_name = f"{self._thread_name_prefix or self}_{num_threads}" + t = threading.Thread( + name=thread_name, + target=_worker, + args=( + weakref.ref(self, weakref_cb), + self._work_queue, + self._initializer, + self._initargs, + ), + daemon=True, + ) + t.start() + self._threads.add(t) + # NOTE: don't add to _threads_queues so we don't block on shutdown diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 719963e37508..fb1f91aec798 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException): logging.exception(e) -def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None: +def _mp_gen_game( + gen_options: dict, + meta: dict[str, Any] | None = None, + owner=None, + sid=None, + timeout: int|None = None, +) -> PrimaryKey | None: from setproctitle import setproctitle setproctitle(f"Generator ({sid})") - res = gen_game(gen_options, meta=meta, owner=owner, sid=sid) - setproctitle(f"Generator (idle)") - return res + try: + return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout) + finally: + setproctitle(f"Generator (idle)") -def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): +def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None: try: meta = json.loads(generation.meta) options = restricted_loads(generation.options) logging.info(f"Generating {generation.id} for {len(options)} players") - pool.apply_async(_mp_gen_game, (options,), - {"meta": meta, - "sid": generation.id, - "owner": generation.owner}, - handle_generation_success, handle_generation_failure) + pool.apply_async( + _mp_gen_game, + (options,), + { + "meta": meta, + "sid": generation.id, + "owner": generation.owner, + "timeout": timeout, + }, + handle_generation_success, + handle_generation_failure, + ) except Exception as e: generation.state = STATE_ERROR commit() @@ -135,6 +149,7 @@ def keep_running(): with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, initargs=(config,), maxtasksperchild=10) as generator_pool: + job_time = config["JOB_TIME"] with db_session: to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) @@ -145,7 +160,7 @@ def keep_running(): if sid: generation.delete() else: - launch_generator(generator_pool, generation) + launch_generator(generator_pool, generation, timeout=job_time) commit() select(generation for generation in Generation if generation.state == STATE_ERROR).delete() @@ -157,7 +172,7 @@ def keep_running(): generation for generation in Generation if generation.state == STATE_QUEUED).for_update() for generation in to_start: - launch_generator(generator_pool, generation) + launch_generator(generator_pool, generation, timeout=job_time) except AlreadyRunningException: logging.info("Autogen reports as already running, not starting another.") diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 1bde8f780578..cb61d1446e77 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -14,7 +14,7 @@ from BaseClasses import get_seed, seeddigits from Generate import PlandoOptions, handle_name, mystery_argparse from Main import main as ERmain -from Utils import __version__, restricted_dumps +from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor from WebHostLib import app from settings import ServerOptions, GeneratorOptions from .check import get_yaml_data, roll_options @@ -107,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): else: try: seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, - meta=meta, owner=session["_id"].int) + meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"]) except BaseException as e: from .autolauncher import handle_generation_failure handle_generation_failure(e) @@ -118,7 +118,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): return redirect(url_for("view_seed", seed=seed_id)) -def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None): +def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None): if meta is None: meta = {} @@ -172,11 +172,12 @@ def task(): ERmain(args, seed, baked_server_options=meta["server_options"]) return upload_to_db(target.name, sid, owner, race) - thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + + thread_pool = DaemonThreadPoolExecutor(max_workers=1) thread = thread_pool.submit(task) try: - return thread.result(app.config["JOB_TIME"]) + return thread.result(timeout) except concurrent.futures.TimeoutError as e: if sid: with db_session: @@ -189,6 +190,9 @@ def task(): format_exception(e)) gen.meta = json.dumps(meta) commit() + except (KeyboardInterrupt, SystemExit): + # don't update db, retry next time + raise except BaseException as e: if sid: with db_session: @@ -200,6 +204,11 @@ def task(): gen.meta = json.dumps(meta) commit() raise + finally: + # free resources claimed by thread pool, if possible + # NOTE: Timeout depends on the process being killed at some point + # since we can't actually cancel a running gen at the moment. + thread_pool.shutdown(wait=False, cancel_futures=True) @app.route('/wait/') diff --git a/test/utils/test_daemon_thread_pool.py b/test/utils/test_daemon_thread_pool.py new file mode 100644 index 000000000000..b8702492311f --- /dev/null +++ b/test/utils/test_daemon_thread_pool.py @@ -0,0 +1,14 @@ +import unittest + +from Utils import DaemonThreadPoolExecutor + + +class DaemonThreadPoolExecutorTest(unittest.TestCase): + def test_is_daemon(self) -> None: + def run() -> None: + pass + + with DaemonThreadPoolExecutor(1) as executor: + executor.submit(run) + + self.assertTrue(next(iter(executor._threads)).daemon) From 708df4d1e2e4f41acb95b158bc4e4e4a23739828 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:06:07 +0200 Subject: [PATCH 27/33] WebHost: Fix flask-compress to 1.18 for Python 3.11 (to get CI to pass again) (#5573) From Discord: Well, flask-compress updated and now our 3.11 CI is failing Why? They switched to a lib called backports.zstd And 3.11 pkg_resources can't handle that. pip finds it. But in our ModuleUpdate.py, we first pkg_resources.require packages, and this fails. I can't reproduce this locally yet, but in CI, it seems like even though backports.zstd is installed, it still fails on it and prompts installing it over and over in every unit test Now what do we do :KEKW: Black Sliver suggested pinning flask-compress for 3.11 But I would just like to point out that this means we can't unpin it until we drop 3.11 the real thing is we probably need to move away from pkg_resources? lol since it's been deprecated literally since the oldest version we support --- WebHostLib/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index f64ed085c982..e7181bd9a791 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -4,7 +4,8 @@ pony>=0.7.19; python_version <= '3.12' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' waitress>=3.0.2 Flask-Caching>=2.3.0 -Flask-Compress>=1.17 +Flask-Compress>=1.17; python_version >= '3.12' +Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency Flask-Limiter>=3.12 bokeh>=3.6.3 markupsafe>=3.0.2 From 7cd73e27109988b85cb019025e130bbccf65138e Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:40:32 +0200 Subject: [PATCH 28/33] WebHost: Fix generate argparse with --config-override + add autogen unit tests so we can test that (#5541) * Fix webhost argparse with extra args * accidentally added line * WebHost: fix some typing B64 url conversion is used in test/hosting, so it felt appropriate to include this here. * Test: Hosting: also test autogen * Test: Hosting: simplify stop_* and leave a note about Windows compat * Test: Hosting: fix formatting error * Test: Hosting: add limitted Windows support There are actually some differences with MP on Windows that make it impossible to run this in CI. --------- Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- Generate.py | 4 +- WebHostLib/__init__.py | 12 +++-- WebHostLib/autolauncher.py | 2 +- WebHostLib/generate.py | 2 +- test/hosting/__main__.py | 93 ++++++++++++++++++++++++++------------ test/hosting/webhost.py | 64 ++++++++++++++++++++++++-- test/hosting/world.py | 2 +- 7 files changed, 136 insertions(+), 43 deletions(-) diff --git a/Generate.py b/Generate.py index 8e8132038509..1044a9880d88 100644 --- a/Generate.py +++ b/Generate.py @@ -23,7 +23,7 @@ from Utils import parse_yamls, version_tuple, __version__, tuplize_version -def mystery_argparse(): +def mystery_argparse(argv: list[str] | None = None): from settings import get_settings settings = get_settings() defaults = settings.generator @@ -57,7 +57,7 @@ def mystery_argparse(): parser.add_argument("--spoiler_only", action="store_true", help="Skips generation assertion and multidata, outputting only a spoiler log. " "Intended for debugging and testing purposes.") - args = parser.parse_args() + args = parser.parse_args(argv) if args.skip_output and args.spoiler_only: parser.error("Cannot mix --skip_output and --spoiler_only") diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 74086cb8842b..e4c2ab83c76c 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -1,6 +1,7 @@ import base64 import os import socket +import typing import uuid from flask import Flask @@ -61,20 +62,21 @@ Compress(app) -def to_python(value): +def to_python(value: str) -> uuid.UUID: return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) -def to_url(value): +def to_url(value: uuid.UUID) -> str: return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') class B64UUIDConverter(BaseConverter): - def to_python(self, value): + def to_python(self, value: str) -> uuid.UUID: return to_python(value) - def to_url(self, value): + def to_url(self, value: typing.Any) -> str: + assert isinstance(value, uuid.UUID) return to_url(value) @@ -84,7 +86,7 @@ def to_url(self, value): app.jinja_env.filters["title_sorted"] = title_sorted -def register(): +def register() -> None: """Import submodules, triggering their registering on flask routing. Note: initializes worlds subsystem.""" import importlib diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index fb1f91aec798..96ffbe9e9540 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -17,7 +17,7 @@ _stop_event = Event() -def stop(): +def stop() -> None: """Stops previously launched threads""" global _stop_event stop_event = _stop_event diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index cb61d1446e77..f80663ff43f1 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -137,7 +137,7 @@ def task(): seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) - args = mystery_argparse() + args = mystery_argparse([]) # Just to set up the Namespace with defaults args.multi = playercount args.seed = seed args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery diff --git a/test/hosting/__main__.py b/test/hosting/__main__.py index e235d7bb7218..8f2a34a7a896 100644 --- a/test/hosting/__main__.py +++ b/test/hosting/__main__.py @@ -3,6 +3,7 @@ # Run with `python test/hosting` instead, import logging import traceback +from pathlib import Path from tempfile import TemporaryDirectory from time import sleep from typing import Any @@ -11,7 +12,7 @@ from test.hosting.generate import generate_local from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, - stop_autohost, upload_multidata) + stop_autogen, stop_autohost, upload_multidata, generate_remote) from test.hosting.world import copy as copy_world, delete as delete_world failure = False @@ -56,35 +57,62 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: if __name__ == "__main__": + import sys import warnings + warnings.simplefilter("ignore", ResourceWarning) warnings.simplefilter("ignore", UserWarning) + warnings.simplefilter("ignore", DeprecationWarning) spacer = '=' * 80 with TemporaryDirectory() as tempdir: + empty_file = str(Path(tempdir) / "empty") + open(empty_file, "w").close() + sys.argv += ["--config_override", empty_file] # tests #5541 multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] - p1_games = [] - data_paths = [] - rooms = [] + p1_games: list[str] = [] + data_paths: list[Path | None] = [] + rooms: list[str] = [] + multidata: Path | None copy_world("VVVVVV", "Temp World") try: for n, games in enumerate(multis, 1): - print(f"Generating [{n}] {', '.join(games)}") + print(f"Generating [{n}] {', '.join(games)} offline") multidata = generate_local(games, tempdir) print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") - p1_games.append(games[0]) data_paths.append(multidata) + p1_games.append(games[0]) finally: delete_world("Temp World") webapp = get_app(tempdir) webhost_client = webapp.test_client() + for n, multidata in enumerate(data_paths, 1): + assert multidata seed = upload_multidata(webhost_client, multidata) + print(f"Uploaded [{n}] {multidata} as {seed}\n") room = create_room(webhost_client, seed) - print(f"Uploaded [{n}] {multidata} as {room}\n") + print(f"Started [{n}] {seed} as {room}\n") + rooms.append(room) + + # Generate 1 extra game on WebHost + from WebHostLib.autolauncher import autogen + for n, games in enumerate(multis[:1], len(multis) + 1): + multis.append(games) + try: + print(f"Generating [{n}] {', '.join(games)} online") + autogen(webapp.config) + sleep(5) # until we have lazy loading of worlds, wait here for the process to start up + seed = generate_remote(webhost_client, games) + print(f"Generated [{n}] {', '.join(games)} as {seed}\n") + finally: + stop_autogen() + data_paths.append(None) # WebHost-only + room = create_room(webhost_client, seed) + print(f"Started [{n}] {seed} as {room}\n") rooms.append(room) print("Starting autohost") @@ -96,31 +124,10 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): involved_games = {"Archipelago"} | set(multi_games) for collected_items in range(3): - print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected") - with LocalServeGame(multidata) as host: - with Client(host.address, game, "Player1") as client: - local_data_packages = client.games_packages - local_collected_items = len(client.checked_locations) - if collected_items < 2: # Don't collect anything on the last iteration - client.collect_any() - # TODO: Ctrl+C test here as well - - for game_name in sorted(involved_games): - expect_true(game_name in local_data_packages, - f"{game_name} missing from MultiServer datap ackage") - expect_true("item_name_groups" not in local_data_packages.get(game_name, {}), - f"item_name_groups are not supposed to be in MultiServer data for {game_name}") - expect_true("location_name_groups" not in local_data_packages.get(game_name, {}), - f"location_name_groups are not supposed to be in MultiServer data for {game_name}") - for game_name in local_data_packages: - expect_true(game_name in involved_games, - f"Received unexpected extra data package for {game_name} from MultiServer") - assert_equal(local_collected_items, collected_items, - "MultiServer did not load or save correctly") - print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") prev_host_adr: str with WebHostServeGame(webhost_client, room) as host: + sleep(.1) # wait for the server to fully start before doing anything prev_host_adr = host.address with Client(host.address, game, "Player1") as client: web_data_packages = client.games_packages @@ -134,6 +141,7 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: autohost(webapp.config) # this will spin the room right up again sleep(1) # make log less annoying # if saving failed, the next iteration will fail below + sleep(2) # work around issue #5571 # verify server shut down try: @@ -156,6 +164,31 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: "customserver did not load or save correctly during/after " + ("Ctrl+C" if collected_items == 2 else "/exit")) + if not multidata: + continue # games rolled on WebHost can not be tested against MultiServer + + print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected") + with LocalServeGame(multidata) as host: + with Client(host.address, game, "Player1") as client: + local_data_packages = client.games_packages + local_collected_items = len(client.checked_locations) + if collected_items < 2: # Don't collect anything on the last iteration + client.collect_any() + # TODO: Ctrl+C test here as well + + for game_name in sorted(involved_games): + expect_true(game_name in local_data_packages, + f"{game_name} missing from MultiServer datapackage") + expect_true("item_name_groups" not in local_data_packages.get(game_name, {}), + f"item_name_groups are not supposed to be in MultiServer data for {game_name}") + expect_true("location_name_groups" not in local_data_packages.get(game_name, {}), + f"location_name_groups are not supposed to be in MultiServer data for {game_name}") + for game_name in local_data_packages: + expect_true(game_name in involved_games, + f"Received unexpected extra data package for {game_name} from MultiServer") + assert_equal(local_collected_items, collected_items, + "MultiServer did not load or save correctly") + # compare customserver to MultiServer expect_equal(local_data_packages, web_data_packages, "customserver datapackage differs from MultiServer") @@ -176,10 +209,12 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: print(f"Restoring multidata for {room}") set_multidata_for_room(webhost_client, room, old_data) with WebHostServeGame(webhost_client, room) as host: + sleep(.1) # wait for the server to fully start before doing anything with Client(host.address, game, "Player1") as client: assert_equal(len(client.checked_locations), 2, "Save was destroyed during exception in customserver") print("Save file is not busted 🥳") + sleep(2) # work around issue #5571 finally: print("Stopping autohost") diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py index 8888c3fb87fc..a8e70a50c20c 100644 --- a/test/hosting/webhost.py +++ b/test/hosting/webhost.py @@ -1,6 +1,10 @@ +import io +import json import re +import time +import zipfile from pathlib import Path -from typing import TYPE_CHECKING, Optional, cast +from typing import TYPE_CHECKING, Iterable, Optional, cast from WebHostLib import to_python @@ -10,6 +14,7 @@ __all__ = [ "get_app", + "generate_remote", "upload_multidata", "create_room", "start_room", @@ -17,6 +22,7 @@ "set_room_timeout", "get_multidata_for_room", "set_multidata_for_room", + "stop_autogen", "stop_autohost", ] @@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask": "TESTING": True, "HOST_ADDRESS": "localhost", "HOSTERS": 1, + "GENERATORS": 1, + "JOB_THRESHOLD": 1, }) return get_app() +def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str: + data = io.BytesIO() + with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file: + for n, game in enumerate(games, 1): + name = f"{n}.yaml" + zip_file.writestr(name, json.dumps({ + "name": f"Player{n}", + "game": game, + game: {}, + "description": f"generate_remote slot {n} ('Player{n}'): {game}", + })) + data.seek(0) + response = app_client.post("/generate", content_type="multipart/form-data", data={ + "file": (data, "yamls.zip"), + }) + assert response.status_code < 400, f"Starting gen failed: status {response.status_code}" + assert "Location" in response.headers, f"Starting gen failed: no redirect" + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}" + for attempt in range(10): + response = app_client.get(location) + if "Location" in response.headers: + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}" + return location[6:] + time.sleep(1) + raise TimeoutError("WebHost gen did not finish") + + def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: response = app_client.post("/uploads", data={ "file": multidata.open("rb"), @@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by room.seed.multidata = data -def stop_autohost(graceful: bool = True) -> None: +def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None: import os import signal @@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None: stop() proc: multiprocessing.process.BaseProcess - for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()): + for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()): + # FIXME: graceful currently does not work on Windows because the signals are not properly emulated + # and ungraceful may not save the game + if proc.pid == os.getpid(): + continue if graceful and proc.pid: os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) else: proc.kill() try: - proc.join(30) + try: + proc.join(30) + except TimeoutError: + raise + except KeyboardInterrupt: + # on Windows, the MP exception may be forwarded to the host, so ignore once and retry + proc.join(30) except TimeoutError: proc.kill() proc.join() + +def stop_autogen(graceful: bool = True) -> None: + # FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool + _stop_webhost_mp("SpawnPoolWorker-", graceful) + +def stop_autohost(graceful: bool = True) -> None: + _stop_webhost_mp("MultiHoster", graceful) diff --git a/test/hosting/world.py b/test/hosting/world.py index cd53453c10c2..20f8df8cb1bb 100644 --- a/test/hosting/world.py +++ b/test/hosting/world.py @@ -11,7 +11,7 @@ def copy(src: str, dst: str) -> None: from Utils import get_file_safe_name - from worlds import AutoWorldRegister + from worlds.AutoWorld import AutoWorldRegister assert dst not in _new_worlds, "World already created" if '"' in dst or "\\" in dst: # easier to reject than to escape From 621ec274c3634cfc9af6b9902bc89ee451a7cafa Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:47:16 -0600 Subject: [PATCH 29/33] Yugioh: Fix likely unintended concatenations (#5567) * Fix likely unintended concatenations * Yeah that makes sense why I thought there were more here --- worlds/yugioh06/boosterpacks.py | 6 +++--- worlds/yugioh06/rules.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/worlds/yugioh06/boosterpacks.py b/worlds/yugioh06/boosterpacks.py index 645977d28def..7fbb56a83895 100644 --- a/worlds/yugioh06/boosterpacks.py +++ b/worlds/yugioh06/boosterpacks.py @@ -423,7 +423,7 @@ "Kaiser Glider", "Horus the Black Flame Dragon LV6", "Luster Dragon", - "Luster Dragon #2" + "Luster Dragon #2", "Spear Dragon", "Armed Dragon LV3", "Armed Dragon LV5", @@ -634,7 +634,7 @@ "Mystic Swordsman LV6", "Horus the Black Flame Dragon LV6", "Horus the Black Flame Dragon LV4", - "Armed Dragon LV3" + "Armed Dragon LV3", "Armed Dragon LV5", "Silent Swordsman Lv3", "Silent Swordsman Lv5", @@ -750,7 +750,7 @@ "Formation Union", "Princess Pikeru", "Skull Zoma", - "Metal Reflect Slime" + "Metal Reflect Slime", "Level Up!", "Howling Insect", "Tribute Doll", diff --git a/worlds/yugioh06/rules.py b/worlds/yugioh06/rules.py index 0b46e0b5d0b0..ce61fa18d2aa 100644 --- a/worlds/yugioh06/rules.py +++ b/worlds/yugioh06/rules.py @@ -668,7 +668,7 @@ def only_dragon(state, player): ], player) and (state.count_from_list_unique([ "Luster Dragon", "Spear Dragon", - "Cave Dragon" + "Cave Dragon", "Armed Dragon LV3", "Masked Dragon", "Twin-Headed Behemoth", From d2bf7fdaf71c40d24312b757364030cf7e96692b Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:47:49 -0600 Subject: [PATCH 30/33] AHiT: Fix likely unintended concatenation #5565 --- worlds/ahit/Regions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/ahit/Regions.py b/worlds/ahit/Regions.py index 857c04f1d7fc..4c6c24be511f 100644 --- a/worlds/ahit/Regions.py +++ b/worlds/ahit/Regions.py @@ -243,7 +243,7 @@ "Time Rift - Mafia of Cooks", "Time Rift - Dead Bird Studio", "Time Rift - Sleepy Subcon", - "Time Rift - Alpine Skyline" + "Time Rift - Alpine Skyline", "Time Rift - Tour", "Time Rift - Rumbi Factory", ] From c199775c488b716b7974cc6d1082a40923a9936a Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:48:17 -0600 Subject: [PATCH 31/33] Pokemon RB: Fix likely unintended concatenation #5566 --- worlds/pokemon_rb/regions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/pokemon_rb/regions.py b/worlds/pokemon_rb/regions.py index 5aa6243514b3..182f1fc79a92 100644 --- a/worlds/pokemon_rb/regions.py +++ b/worlds/pokemon_rb/regions.py @@ -1237,7 +1237,7 @@ entrance_only = [ "Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F", - "Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F" + "Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F", "Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W", "Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House", "Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E", From e8c8b0dbc59c59d19bceb09c0f6b877a8d96bd04 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:10:39 -0500 Subject: [PATCH 32/33] MM2: fix Proteus reading #5575 --- worlds/mm2/rom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/mm2/rom.py b/worlds/mm2/rom.py index 97e33bf3c46d..3c31067ffa69 100644 --- a/worlds/mm2/rom.py +++ b/worlds/mm2/rom.py @@ -16,7 +16,7 @@ from . import MM2World MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497" -PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4" +PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61" MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632" MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3" @@ -404,7 +404,7 @@ def get_base_rom_path(file_name: str = "") -> str: return file_name -PRG_OFFSET = 0x8ED70 +PRG_OFFSET = 0x8F170 PRG_SIZE = 0x40000 From 3105320038a6cbeb0b443a09a8338da31e574deb Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:52:44 +0000 Subject: [PATCH 33/33] Test: check fields in world source manifest (#5558) * Test: check game in world manifest * Update test/general/test_world_manifest.py Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com> * Test: rework finding expected manifest location * Test: fix doc comment * Test: fix wrong custom_worlds path in test_world_manifest Also simplifies the way we find ./worlds/. * Test: make test_world_manifest easier to extend * Test: check world_version in world manifest according to docs/apworld specification.md * Test: check no container version in source world manifest according what was added to docs/apworld specification.md in PR 5509 * Test: better assertion messages in test_world_manifest.py * Test: fix wording in world source manifest --------- Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com> --- test/general/test_world_manifest.py | 102 ++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 test/general/test_world_manifest.py diff --git a/test/general/test_world_manifest.py b/test/general/test_world_manifest.py new file mode 100644 index 000000000000..3aaa93b0ac19 --- /dev/null +++ b/test/general/test_world_manifest.py @@ -0,0 +1,102 @@ +"""Check world sources' manifest files""" + +import json +import unittest +from pathlib import Path +from typing import Any, ClassVar + +import test +from Utils import home_path, local_path +from worlds.AutoWorld import AutoWorldRegister +from ..param import classvar_matrix + + +test_path = Path(test.__file__).parent +worlds_paths = [ + Path(local_path("worlds")), + Path(local_path("custom_worlds")), + Path(home_path("worlds")), + Path(home_path("custom_worlds")), +] + +# Only check source folders for now. Zip validation should probably be in the loader and/or installer. +source_world_names = [ + k + for k, v in AutoWorldRegister.world_types.items() + if not v.zip_path and not Path(v.__file__).is_relative_to(test_path) +] + + +def get_source_world_manifest_path(game: str) -> Path | None: + """Get path of archipelago.json in the world's root folder from game name.""" + # TODO: add a feature to AutoWorld that makes this less annoying + world_type = AutoWorldRegister.world_types[game] + world_type_path = Path(world_type.__file__) + for worlds_path in worlds_paths: + if world_type_path.is_relative_to(worlds_path): + world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0] + manifest_path = world_root / "archipelago.json" + return manifest_path if manifest_path.exists() else None + assert False, f"{world_type_path} not found in any worlds path" + + +# TODO: remove the filter once manifests are mandatory. +@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names)) +class TestWorldManifest(unittest.TestCase): + game: ClassVar[str] + manifest: ClassVar[dict[str, Any]] + + @classmethod + def setUpClass(cls) -> None: + world_type = AutoWorldRegister.world_types[cls.game] + assert world_type.game == cls.game + manifest_path = get_source_world_manifest_path(cls.game) + assert manifest_path # make mypy happy + with manifest_path.open("r", encoding="utf-8") as f: + cls.manifest = json.load(f) + + def test_game(self) -> None: + """Test that 'game' will be correctly defined when generating APWorld manifest from source.""" + self.assertIn( + "game", + self.manifest, + f"archipelago.json manifest exists for {self.game} but does not contain 'game'", + ) + self.assertEqual( + self.manifest["game"], + self.game, + f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'", + ) + + def test_world_version(self) -> None: + """Test that world_version matches the requirements in apworld specification.md""" + if "world_version" in self.manifest: + world_version: str = self.manifest["world_version"] + self.assertIsInstance( + world_version, + str, + f"world_version in archipelago.json for '{self.game}' has to be string if provided.", + ) + parts = world_version.split(".") + self.assertEqual( + len(parts), + 3, + f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.", + ) + for part in parts: + self.assertTrue( + part.isdigit(), + f"world_version in archipelago.json for '{self.game}' may only contain numbers.", + ) + + def test_no_container_version(self) -> None: + self.assertNotIn( + "version", + self.manifest, + f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.", + ) + self.assertNotIn( + "compatible_version", + self.manifest, + f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.", + )