From 52726139b4632a0ce9a4a0aa717b80d6ce460eed Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Sun, 23 Oct 2022 09:18:05 -0700 Subject: [PATCH 01/17] Zillion: support unicode player names (#1131) * work on unicode and seed verification * update zilliandomizer * fix log message --- CommonClient.py | 2 + ZillionClient.py | 83 +++++++++++++++++++++++---------- worlds/zillion/__init__.py | 3 +- worlds/zillion/requirements.txt | 2 +- 4 files changed, 64 insertions(+), 26 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index b17709eecf..7960be0e92 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -279,6 +279,7 @@ class CommonContext: self.auth = await self.console_input() async def send_connect(self, **kwargs: typing.Any) -> None: + """ send `Connect` packet to log in to server """ payload = { 'cmd': 'Connect', 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, @@ -294,6 +295,7 @@ class CommonContext: return await self.input_queue.get() async def connect(self, address: typing.Optional[str] = None) -> None: + """ disconnect any previous connection, and open new connection to the server """ await self.disconnect() self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") diff --git a/ZillionClient.py b/ZillionClient.py index dee5c2b756..8ad1065057 100644 --- a/ZillionClient.py +++ b/ZillionClient.py @@ -1,7 +1,7 @@ import asyncio import base64 import platform -from typing import Any, Coroutine, Dict, Optional, Type, cast +from typing import Any, Coroutine, Dict, Optional, Tuple, Type, cast # CommonClient import first to trigger ModuleUpdater from CommonClient import CommonContext, server_loop, gui_enabled, \ @@ -46,6 +46,8 @@ class ZillionContext(CommonContext): start_char: Chars = "JJ" rescues: Dict[int, RescueInfo] = {} loc_mem_to_id: Dict[int, int] = {} + got_room_info: asyncio.Event + """ flag for connected to server """ got_slot_data: asyncio.Event """ serves as a flag for whether I am logged in to the server """ @@ -65,6 +67,7 @@ class ZillionContext(CommonContext): super().__init__(server_address, password) self.from_game = asyncio.Queue() self.to_game = asyncio.Queue() + self.got_room_info = asyncio.Event() self.got_slot_data = asyncio.Event() self.look_for_retroarch = asyncio.Event() @@ -185,6 +188,9 @@ class ZillionContext(CommonContext): logger.info("received door data from server") doors = base64.b64decode(doors_b64) self.to_game.put_nowait(events.DoorEventToGame(doors)) + elif cmd == "RoomInfo": + self.seed_name = args["seed_name"] + self.got_room_info.set() def process_from_game_queue(self) -> None: if self.from_game.qsize(): @@ -238,6 +244,24 @@ class ZillionContext(CommonContext): self.next_item = len(self.items_received) +def name_seed_from_ram(data: bytes) -> Tuple[str, str]: + """ returns player name, and end of seed string """ + if len(data) == 0: + # no connection to game + return "", "xxx" + null_index = data.find(b'\x00') + if null_index == -1: + logger.warning(f"invalid game id in rom {data}") + null_index = len(data) + name = data[:null_index].decode() + null_index_2 = data.find(b'\x00', null_index + 1) + if null_index_2 == -1: + null_index_2 = len(data) + seed_name = data[null_index + 1:null_index_2].decode() + + return name, seed_name + + async def zillion_sync_task(ctx: ZillionContext) -> None: logger.info("started zillion sync task") @@ -263,47 +287,58 @@ async def zillion_sync_task(ctx: ZillionContext) -> None: with Memory(ctx.from_game, ctx.to_game) as memory: while not ctx.exit_event.is_set(): ram = await memory.read() - name = memory.get_player_name(ram).decode() + game_id = memory.get_rom_to_ram_data(ram) + name, seed_end = name_seed_from_ram(game_id) if len(name): if name == ctx.auth: # this is the name we know if ctx.server and ctx.server.socket: # type: ignore - if memory.have_generation_info(): - log_no_spam("everything connected") - await memory.process_ram(ram) - ctx.process_from_game_queue() - ctx.process_items_received() - else: # no generation info - if ctx.got_slot_data.is_set(): - memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id) - ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \ - make_id_to_others(ctx.start_char) - ctx.next_item = 0 - ctx.ap_local_count = len(ctx.checked_locations) - else: # no slot data yet - asyncio.create_task(ctx.send_connect()) - log_no_spam("logging in to server...") - await asyncio.wait(( - ctx.got_slot_data.wait(), - ctx.exit_event.wait(), - asyncio.sleep(6) - ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets + if ctx.got_room_info.is_set(): + if ctx.seed_name and ctx.seed_name.endswith(seed_end): + # correct seed + if memory.have_generation_info(): + log_no_spam("everything connected") + await memory.process_ram(ram) + ctx.process_from_game_queue() + ctx.process_items_received() + else: # no generation info + if ctx.got_slot_data.is_set(): + memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id) + ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \ + make_id_to_others(ctx.start_char) + ctx.next_item = 0 + ctx.ap_local_count = len(ctx.checked_locations) + else: # no slot data yet + asyncio.create_task(ctx.send_connect()) + log_no_spam("logging in to server...") + await asyncio.wait(( + ctx.got_slot_data.wait(), + ctx.exit_event.wait(), + asyncio.sleep(6) + ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets + else: # not correct seed name + log_no_spam("incorrect seed - did you mix up roms?") + else: # no room info + # If we get here, it looks like `RoomInfo` packet got lost + log_no_spam("waiting for room info from server...") else: # server not connected log_no_spam("waiting for server connection...") else: # new game log_no_spam("connected to new game") await ctx.disconnect() ctx.reset_server_state() + ctx.seed_name = None + ctx.got_room_info.clear() ctx.reset_game_state() memory.reset_game_state() ctx.auth = name asyncio.create_task(ctx.connect()) await asyncio.wait(( - ctx.got_slot_data.wait(), + ctx.got_room_info.wait(), ctx.exit_event.wait(), asyncio.sleep(6) - ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets + ), return_when=asyncio.FIRST_COMPLETED) else: # no name found in game if not help_message_shown: logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.') diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index 32b84015f1..d982782840 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -304,7 +304,8 @@ class ZillionWorld(World): zz_patcher.all_fixes_and_options(zz_options) zz_patcher.set_external_item_interface(zz_options.start_char, zz_options.max_level) zz_patcher.set_multiworld_items(multi_items) - zz_patcher.set_rom_to_ram_data(self.world.player_name[self.player].replace(' ', '_').encode()) + game_id = self.world.player_name[self.player].encode() + b'\x00' + self.world.seed_name[-6:].encode() + zz_patcher.set_rom_to_ram_data(game_id) def generate_output(self, output_directory: str) -> None: """This method gets called from a threadpool, do not use world.random here. diff --git a/worlds/zillion/requirements.txt b/worlds/zillion/requirements.txt index 0ed98771bd..62f66899f3 100644 --- a/worlds/zillion/requirements.txt +++ b/worlds/zillion/requirements.txt @@ -1 +1 @@ -git+https://github.com/beauxq/zilliandomizer@45a45eaca4119a4d06d2c31546ad19f3abd77f63#egg=zilliandomizer==0.4.4 +git+https://github.com/beauxq/zilliandomizer@c97298ecb1bca58c3dd3376a1e1609fad53788cf#egg=zilliandomizer==0.4.5 From 37c5865c0e34cf087df2f8efea7a3e0b637440b5 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Sun, 23 Oct 2022 09:28:09 -0700 Subject: [PATCH 02/17] Core: Options: fix shared default instances (#1130) --- Options.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Options.py b/Options.py index c2007c1c41..536f388efb 100644 --- a/Options.py +++ b/Options.py @@ -1,5 +1,6 @@ from __future__ import annotations import abc +from copy import deepcopy import math import numbers import typing @@ -753,7 +754,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): supports_weighting = False def __init__(self, value: typing.Dict[str, typing.Any]): - self.value = value + self.value = deepcopy(value) @classmethod def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: @@ -784,7 +785,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys): supports_weighting = False def __init__(self, value: typing.List[typing.Any]): - self.value = value or [] + self.value = deepcopy(value) super(OptionList, self).__init__() @classmethod @@ -806,11 +807,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys): - default = frozenset() + default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset() supports_weighting = False - def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]): - self.value = set(value) + def __init__(self, value: typing.Iterable[str]): + self.value = set(deepcopy(value)) super(OptionSet, self).__init__() @classmethod From ad445629bde627983ab3dd33e0765547836f1ae0 Mon Sep 17 00:00:00 2001 From: beauxq Date: Sun, 23 Oct 2022 13:23:30 -0700 Subject: [PATCH 03/17] Zillion: fix unit tests previous fix was incorrect --- test/worlds/test_base.py | 4 ++-- test/worlds/zillion/TestGoal.py | 31 ++++++++++++++++++------------- test/worlds/zillion/__init__.py | 21 ++++++++++++++------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/test/worlds/test_base.py b/test/worlds/test_base.py index 1aa6ff2317..0d7272be19 100644 --- a/test/worlds/test_base.py +++ b/test/worlds/test_base.py @@ -19,13 +19,13 @@ class WorldTestBase(unittest.TestCase): if self.auto_construct: self.world_setup() - def world_setup(self) -> None: + def world_setup(self, seed: typing.Optional[int] = None) -> None: if not hasattr(self, "game"): raise NotImplementedError("didn't define game name") self.world = MultiWorld(1) self.world.game[1] = self.game self.world.player_name = {1: "Tester"} - self.world.set_seed() + self.world.set_seed(seed) args = Namespace() for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items(): setattr(args, name, { diff --git a/test/worlds/zillion/TestGoal.py b/test/worlds/zillion/TestGoal.py index 96701e8352..1c79305699 100644 --- a/test/worlds/zillion/TestGoal.py +++ b/test/worlds/zillion/TestGoal.py @@ -10,7 +10,7 @@ class TestGoalVanilla(ZillionTestBase): "floppy_req": 6, } - def test_floppies(self): + def test_floppies(self) -> None: self.collect_by_name(["Apple", "Champ", "Red ID Card"]) self.assertBeatable(False) # 0 floppies floppies = self.get_items_by_name("Floppy Disk") @@ -26,19 +26,20 @@ class TestGoalVanilla(ZillionTestBase): self.assertEqual(self.count("Floppy Disk"), 7) self.assertBeatable(True) - def test_with_everything(self): + def test_with_everything(self) -> None: self.collect_by_name(["Apple", "Champ", "Red ID Card", "Floppy Disk"]) self.assertBeatable(True) - def test_no_jump(self): + def test_no_jump(self) -> None: self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk"]) self.assertBeatable(False) - def test_no_gun(self): + def test_no_gun(self) -> None: + self.ensure_gun_3_requirement() self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk"]) self.assertBeatable(False) - def test_no_red(self): + def test_no_red(self) -> None: self.collect_by_name(["Apple", "Champ", "Floppy Disk"]) self.assertBeatable(False) @@ -50,7 +51,7 @@ class TestGoalBalanced(ZillionTestBase): "gun_levels": "balanced", } - def test_jump(self): + def test_jump(self) -> None: self.collect_by_name(["Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump opas = self.get_items_by_name("Opa-Opa") @@ -60,7 +61,8 @@ class TestGoalBalanced(ZillionTestBase): self.collect(opas[1:]) self.assertBeatable(True) - def test_guns(self): + def test_guns(self) -> None: + self.ensure_gun_3_requirement() self.collect_by_name(["Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun guns = self.get_items_by_name("Zillion") @@ -78,7 +80,7 @@ class TestGoalRestrictive(ZillionTestBase): "gun_levels": "restrictive", } - def test_jump(self): + def test_jump(self) -> None: self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump self.collect_by_name("Opa-Opa") @@ -86,7 +88,8 @@ class TestGoalRestrictive(ZillionTestBase): self.collect_by_name("Apple") self.assertBeatable(True) - def test_guns(self): + def test_guns(self) -> None: + self.ensure_gun_3_requirement() self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun self.collect_by_name("Zillion") @@ -104,15 +107,17 @@ class TestGoalAppleStart(ZillionTestBase): "zillion_count": 5 } - def test_guns_jj_first(self): + def test_guns_jj_first(self) -> None: """ with low gun levels, 5 Zillion is enough to get JJ to gun 3 """ + self.ensure_gun_3_requirement() self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun self.collect_by_name("Zillion") self.assertBeatable(True) - def test_guns_zillions_first(self): + def test_guns_zillions_first(self) -> None: """ with low gun levels, 5 Zillion is enough to get JJ to gun 3 """ + self.ensure_gun_3_requirement() self.collect_by_name(["Zillion", "Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun self.collect_by_name("JJ") @@ -129,14 +134,14 @@ class TestGoalChampStart(ZillionTestBase): "opas_per_level": 1 } - def test_jump_jj_first(self): + def test_jump_jj_first(self) -> None: """ with low jump levels, 5 level-ups is enough to get JJ to jump 3 """ self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump self.collect_by_name("Opa-Opa") self.assertBeatable(True) - def test_jump_opa_first(self): + def test_jump_opa_first(self) -> None: """ with low jump levels, 5 level-ups is enough to get JJ to jump 3 """ self.collect_by_name(["Opa-Opa", "Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump diff --git a/test/worlds/zillion/__init__.py b/test/worlds/zillion/__init__.py index 43100d3a8c..fb81bda522 100644 --- a/test/worlds/zillion/__init__.py +++ b/test/worlds/zillion/__init__.py @@ -1,13 +1,20 @@ +from typing import cast from test.worlds.test_base import WorldTestBase -from worlds.zillion.region import ZillionLocation +from worlds.zillion import ZillionWorld class ZillionTestBase(WorldTestBase): game = "Zillion" - def world_setup(self) -> None: - super().world_setup() - # make sure game requires gun 3 for tests - for location in self.world.get_locations(): - if isinstance(location, ZillionLocation) and location.name.startswith("O-7"): - location.zz_loc.req.gun = 3 + def ensure_gun_3_requirement(self) -> None: + """ + There's a low probability that gun 3 is not required. + + This makes sure that gun 3 is required by making all the canisters + in O-7 (including key word canisters) require gun 3. + """ + zz_world = cast(ZillionWorld, self.world.worlds[1]) + assert zz_world.zz_system.randomizer + for zz_loc_name, zz_loc in zz_world.zz_system.randomizer.locations.items(): + if zz_loc_name.startswith("r15c6"): + zz_loc.req.gun = 3 From 89d1a80e016194bb8c7b2bf8f69cc6081f409ace Mon Sep 17 00:00:00 2001 From: lordlou <87331798+lordlou@users.noreply.github.com> Date: Mon, 24 Oct 2022 04:28:08 -0400 Subject: [PATCH 04/17] SM: morph first in pool remove (#1134) * 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 * 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 * removed now unecessary sorting of Morph balls at end of item pool Its messing with priority locations feature. --- worlds/sm/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index d901303215..500233bb71 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -657,12 +657,6 @@ class SMWorld(World): loc.place_locked_item(item) loc.address = loc.item.code = None - @classmethod - def stage_fill_hook(cls, world, progitempool, usefulitempool, filleritempool, fill_locations): - if world.get_game_players("Super Metroid"): - progitempool.sort( - key=lambda item: 1 if (item.name == 'Morph Ball') else 0) - @classmethod def stage_post_fill(cls, world): new_state = CollectionState(world) From 6535836e5cec984354ba2680093a2c773fb90da8 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 21 Oct 2022 22:50:36 +0200 Subject: [PATCH 05/17] Subnautica: don't override plando during pre_fill --- worlds/subnautica/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index 7dc23bf405..830bc831ef 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -153,7 +153,8 @@ class SubnauticaWorld(World): return self.prefill_items def pre_fill(self) -> None: - reachable = self.world.get_reachable_locations(player=self.player) + reachable = [location for location in self.world.get_reachable_locations(player=self.player) + if not location.item] self.world.random.shuffle(reachable) items = self.prefill_items.copy() for item in items: From d5efc713444dec62a5d398561303ff3edbf0db6f Mon Sep 17 00:00:00 2001 From: PoryGone <98504756+PoryGone@users.noreply.github.com> Date: Tue, 25 Oct 2022 13:54:43 -0400 Subject: [PATCH 06/17] Core: SNI Client Refactor (#1083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First Pass removal of game-specific code * SMW, DKC3, and SM hooked into AutoClient * All SNES autoclients functional * Fix ALttP Deathlink * Don't default to being ALttP, and properly error check ctx.game * Adjust variable naming * In response to: > we should probably document usage somewhere. I'm open to suggestions of where this should be documented. I think the most valuable documentation for APIs is docstrings and full typing. about websockets change in imports - from websockets documentation: > For convenience, many public APIs can be imported from the websockets package. However, this feature is incompatible with static code analysis. It breaks autocompletion in an IDE or type checking with mypy. If you’re using such tools, use the real import paths. * todo note for python 3.11 typing.NotRequired * missed staging in previous commit * added missing death Game States for DeathLink Co-authored-by: beauxq Co-authored-by: lordlou <87331798+lordlou@users.noreply.github.com> --- CommonClient.py | 6 + LttPAdjuster.py | 4 +- MultiServer.py | 6 +- Patch.py | 10 - SNIClient.py | 1129 ++++++-------------------------------- Utils.py | 15 +- host.yaml | 44 +- worlds/AutoSNIClient.py | 42 ++ worlds/alttp/Client.py | 693 +++++++++++++++++++++++ worlds/alttp/__init__.py | 1 + worlds/dkc3/Client.py | 64 +-- worlds/dkc3/__init__.py | 1 + worlds/sm/Client.py | 158 ++++++ worlds/sm/__init__.py | 1 + worlds/smw/Client.py | 145 +++-- worlds/smw/__init__.py | 1 + worlds/smz3/Client.py | 118 ++++ worlds/smz3/__init__.py | 1 + 18 files changed, 1304 insertions(+), 1135 deletions(-) create mode 100644 worlds/AutoSNIClient.py create mode 100644 worlds/alttp/Client.py create mode 100644 worlds/sm/Client.py create mode 100644 worlds/smz3/Client.py diff --git a/CommonClient.py b/CommonClient.py index 7960be0e92..c713373592 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -91,12 +91,18 @@ class ClientCommandProcessor(CommandProcessor): def _cmd_items(self): """List all item names for the currently running game.""" + if not self.ctx.game: + self.output("No game set, cannot determine existing items.") + return False self.output(f"Item Names for {self.ctx.game}") for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: self.output(item_name) def _cmd_locations(self): """List all location names for the currently running game.""" + if not self.ctx.game: + self.output("No game set, cannot determine existing locations.") + return False self.output(f"Location Names for {self.ctx.game}") for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: self.output(location_name) diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 9fab226c67..a2cc2eeba5 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -26,7 +26,9 @@ ModuleUpdate.update() from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ get_adjuster_settings, tkinter_center_window, init_logging -from Patch import GAME_ALTTP + + +GAME_ALTTP = "A Link to the Past" class AdjusterWorld(object): diff --git a/MultiServer.py b/MultiServer.py index 9f0865d425..bab762c84b 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -998,7 +998,11 @@ class CommandMeta(type): return super(CommandMeta, cls).__new__(cls, name, bases, attrs) -def mark_raw(function): +_Return = typing.TypeVar("_Return") +# TODO: when python 3.10 is lowest supported, typing.ParamSpec + + +def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]: function.raw_text = True return function diff --git a/Patch.py b/Patch.py index 4ff0e9602a..113d0658c6 100644 --- a/Patch.py +++ b/Patch.py @@ -11,16 +11,6 @@ if __name__ == "__main__": from worlds.Files import AutoPatchRegister, APDeltaPatch -GAME_ALTTP = "A Link to the Past" -GAME_SM = "Super Metroid" -GAME_SOE = "Secret of Evermore" -GAME_SMZ3 = "SMZ3" -GAME_DKC3 = "Donkey Kong Country 3" - -GAME_SMW = "Super Mario World" - - - class RomMeta(TypedDict): server: str player: Optional[int] diff --git a/SNIClient.py b/SNIClient.py index 188822bce7..03e1ff5783 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -7,7 +7,6 @@ import multiprocessing import os import subprocess import base64 -import shutil import logging import asyncio import enum @@ -20,24 +19,19 @@ from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui import Utils +from MultiServer import mark_raw +if typing.TYPE_CHECKING: + from worlds.AutoSNIClient import SNIClient + if __name__ == "__main__": Utils.init_logging("SNIClient", exception_logger="Client") import colorama -import websockets - -from NetUtils import ClientStatus, color -from worlds.alttp import Regions, Shops -from worlds.alttp.Rom import ROM_PLAYER_LIMIT -from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT -from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT -from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3, GAME_SMW - +from websockets.client import connect as websockets_connect, WebSocketClientProtocol +from websockets.exceptions import WebSocketException, ConnectionClosed snes_logger = logging.getLogger("SNES") -from MultiServer import mark_raw - class DeathState(enum.IntEnum): killing_player = 1 @@ -46,9 +40,9 @@ class DeathState(enum.IntEnum): class SNIClientCommandProcessor(ClientCommandProcessor): - ctx: Context + ctx: SNIContext - def _cmd_slow_mode(self, toggle: str = ""): + def _cmd_slow_mode(self, toggle: str = "") -> None: """Toggle slow mode, which limits how fast you send / receive items.""" if toggle: self.ctx.slow_mode = toggle.lower() in {"1", "true", "on"} @@ -63,6 +57,9 @@ class SNIClientCommandProcessor(ClientCommandProcessor): otherwise show available devices; and a SNES device number if more than one SNES is detected. Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """ + return self.connect_to_snes(snes_options) + + def connect_to_snes(self, snes_options: str = "") -> bool: snes_address = self.ctx.snes_address snes_device_number = -1 @@ -79,8 +76,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor): self.ctx.snes_reconnect_address = None if self.ctx.snes_connect_task: self.ctx.snes_connect_task.cancel() - self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), - name="SNES Connect") + self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), + name="SNES Connect") return True def _cmd_snes_close(self) -> bool: @@ -113,14 +110,36 @@ class SNIClientCommandProcessor(ClientCommandProcessor): # return True -class Context(CommonContext): - command_processor = SNIClientCommandProcessor - game = "A Link to the Past" +class SNIContext(CommonContext): + command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor + game = None # set in validate_rom items_handling = None # set in game_watcher - snes_connect_task: typing.Optional[asyncio.Task] = None + snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None - def __init__(self, snes_address, server_address, password): - super(Context, self).__init__(server_address, password) + snes_address: str + snes_socket: typing.Optional[WebSocketClientProtocol] + snes_state: SNESState + snes_attached_device: typing.Optional[typing.Tuple[int, str]] + snes_reconnect_address: typing.Optional[str] + snes_recv_queue: "asyncio.Queue[bytes]" + snes_request_lock: asyncio.Lock + snes_write_buffer: typing.List[typing.Tuple[int, bytes]] + snes_connector_lock: threading.Lock + death_state: DeathState + killing_player_task: "typing.Optional[asyncio.Task[None]]" + allow_collect: bool + slow_mode: bool + + client_handler: typing.Optional[SNIClient] + awaiting_rom: bool + rom: typing.Optional[bytes] + prev_rom: typing.Optional[bytes] + + hud_message_queue: typing.List[str] # TODO: str is a guess, is this right? + death_link_allow_survive: bool + + def __init__(self, snes_address: str, server_address: str, password: str) -> None: + super(SNIContext, self).__init__(server_address, password) # snes stuff self.snes_address = snes_address @@ -137,39 +156,48 @@ class Context(CommonContext): self.allow_collect = False self.slow_mode = False + self.client_handler = None self.awaiting_rom = False self.rom = None self.prev_rom = None - async def connection_closed(self): - await super(Context, self).connection_closed() + async def connection_closed(self) -> None: + await super(SNIContext, self).connection_closed() self.awaiting_rom = False - def event_invalid_slot(self): + def event_invalid_slot(self) -> typing.NoReturn: if self.snes_socket is not None and not self.snes_socket.closed: asyncio.create_task(self.snes_socket.close()) raise Exception("Invalid ROM detected, " "please verify that you have loaded the correct rom and reconnect your snes (/snes)") - async def server_auth(self, password_requested: bool = False): + async def server_auth(self, password_requested: bool = False) -> None: if password_requested and not self.password: - await super(Context, self).server_auth(password_requested) + await super(SNIContext, self).server_auth(password_requested) if self.rom is None: self.awaiting_rom = True snes_logger.info( "No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)") return self.awaiting_rom = False + # TODO: This looks kind of hacky... + # Context.auth is meant to be the "name" parameter in send_connect, + # which has to be a str (bytes is not json serializable). + # But here, Context.auth is being used for something else + # (where it has to be bytes because it is compared with rom elsewhere). + # If we need to save something to compare with rom elsewhere, + # it should probably be in a different variable, + # and let auth be used for what it's meant for. self.auth = self.rom auth = base64.b64encode(self.rom).decode() await self.send_connect(name=auth) - def on_deathlink(self, data: dict): + def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: if not self.killing_player_task or self.killing_player_task.done(): self.killing_player_task = asyncio.create_task(deathlink_kill_player(self)) - super(Context, self).on_deathlink(data) + super(SNIContext, self).on_deathlink(data) - async def handle_deathlink_state(self, currently_dead: bool): + async def handle_deathlink_state(self, currently_dead: bool) -> None: # in this state we only care about triggering a death send if self.death_state == DeathState.alive: if currently_dead: @@ -184,25 +212,27 @@ class Context(CommonContext): if not currently_dead: self.death_state = DeathState.alive - async def shutdown(self): - await super(Context, self).shutdown() + async def shutdown(self) -> None: + await super(SNIContext, self).shutdown() if self.snes_connect_task: try: await asyncio.wait_for(self.snes_connect_task, 1) except asyncio.TimeoutError: self.snes_connect_task.cancel() - def on_package(self, cmd: str, args: dict): + def on_package(self, cmd: str, args: typing.Dict[str, typing.Any]) -> None: if cmd in {"Connected", "RoomUpdate"}: if "checked_locations" in args and args["checked_locations"]: new_locations = set(args["checked_locations"]) self.checked_locations |= new_locations self.locations_scouted |= new_locations - # Items belonging to the player should not be marked as checked in game, since the player will likely need that item. - # Once the games handled by SNIClient gets made to be remote items, this will no longer be needed. + # Items belonging to the player should not be marked as checked in game, + # since the player will likely need that item. + # Once the games handled by SNIClient gets made to be remote items, + # this will no longer be needed. asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}])) - def run_gui(self): + def run_gui(self) -> None: from kvui import GameManager class SNIManager(GameManager): @@ -213,391 +243,23 @@ class Context(CommonContext): base_title = "Archipelago SNI Client" self.ui = SNIManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") # type: ignore -async def deathlink_kill_player(ctx: Context): +async def deathlink_kill_player(ctx: SNIContext) -> None: ctx.death_state = DeathState.killing_player while ctx.death_state == DeathState.killing_player and \ ctx.snes_state == SNESState.SNES_ATTACHED: - if ctx.game == GAME_ALTTP: - invincible = await snes_read(ctx, WRAM_START + 0x037B, 1) - last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1) - await asyncio.sleep(0.25) - health = await snes_read(ctx, WRAM_START + 0xF36D, 1) - if not invincible or not last_health or not health: - ctx.death_state = DeathState.dead - ctx.last_death_link = time.time() - continue - if not invincible[0] and last_health[0] == health[0]: - snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0 - snes_buffered_write(ctx, WRAM_START + 0x0373, - bytes([8])) # deal 1 full heart of damage at next opportunity - elif ctx.game == GAME_SM: - snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy) - snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity - if not ctx.death_link_allow_survive: - snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0 - elif ctx.game == GAME_SMW: - from worlds.smw.Client import deathlink_kill_player as smw_deathlink_kill_player - await smw_deathlink_kill_player(ctx) - await snes_flush_writes(ctx) - await asyncio.sleep(1) + if ctx.client_handler is None: + continue + + await ctx.client_handler.deathlink_kill_player(ctx) - if ctx.game == GAME_ALTTP: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - if not gamemode or gamemode[0] in DEATH_MODES: - ctx.death_state = DeathState.dead - elif ctx.game == GAME_SM: - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - health = await snes_read(ctx, WRAM_START + 0x09C2, 2) - if health is not None: - health = health[0] | (health[1] << 8) - if not gamemode or gamemode[0] in SM_DEATH_MODES or ( - ctx.death_link_allow_survive and health is not None and health > 0): - ctx.death_state = DeathState.dead - elif ctx.game == GAME_DKC3: - from worlds.dkc3.Client import deathlink_kill_player as dkc3_deathlink_kill_player - await dkc3_deathlink_kill_player(ctx) ctx.last_death_link = time.time() -SNES_RECONNECT_DELAY = 5 - -# FXPAK Pro protocol memory mapping used by SNI -ROM_START = 0x000000 -WRAM_START = 0xF50000 -WRAM_SIZE = 0x20000 -SRAM_START = 0xE00000 - -ROMNAME_START = SRAM_START + 0x2000 -ROMNAME_SIZE = 0x15 - -INGAME_MODES = {0x07, 0x09, 0x0b} -ENDGAME_MODES = {0x19, 0x1a} -DEATH_MODES = {0x12} - -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - -RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes -RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte -RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte -ROOMID_ADDR = SAVEDATA_START + 0x4D4 # 2 bytes -ROOMDATA_ADDR = SAVEDATA_START + 0x4D6 # 1 byte -SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte -SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte -SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte -SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte -SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes -SHOP_LEN = (len(Shops.shop_table) * 3) + 5 - -DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte - -# SM -SM_ROMNAME_START = ROM_START + 0x007FC0 - -SM_INGAME_MODES = {0x07, 0x09, 0x0b} -SM_ENDGAME_MODES = {0x26, 0x27} -SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} - -# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue -SM_RECV_QUEUE_START = SRAM_START + 0x2000 -SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 -SM_SEND_QUEUE_START = SRAM_START + 0x2700 -SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680 -SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682 - -SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte -SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte - -# SMZ3 -SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 - -SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b} -SMZ3_ENDGAME_MODES = {0x26, 0x27} -SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} - -SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes -SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte -SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte - - -location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()]) - -location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), - "Blind's Hideout - Left": (0x11d, 0x20), - "Blind's Hideout - Right": (0x11d, 0x40), - "Blind's Hideout - Far Left": (0x11d, 0x80), - "Blind's Hideout - Far Right": (0x11d, 0x100), - 'Secret Passage': (0x55, 0x10), - 'Waterfall Fairy - Left': (0x114, 0x10), - 'Waterfall Fairy - Right': (0x114, 0x20), - "King's Tomb": (0x113, 0x10), - 'Floodgate Chest': (0x10b, 0x10), - "Link's House": (0x104, 0x10), - 'Kakariko Tavern': (0x103, 0x10), - 'Chicken House': (0x108, 0x10), - "Aginah's Cave": (0x10a, 0x10), - "Sahasrahla's Hut - Left": (0x105, 0x10), - "Sahasrahla's Hut - Middle": (0x105, 0x20), - "Sahasrahla's Hut - Right": (0x105, 0x40), - 'Kakariko Well - Top': (0x2f, 0x10), - 'Kakariko Well - Left': (0x2f, 0x20), - 'Kakariko Well - Middle': (0x2f, 0x40), - 'Kakariko Well - Right': (0x2f, 0x80), - 'Kakariko Well - Bottom': (0x2f, 0x100), - 'Lost Woods Hideout': (0xe1, 0x200), - 'Lumberjack Tree': (0xe2, 0x200), - 'Cave 45': (0x11b, 0x400), - 'Graveyard Cave': (0x11b, 0x200), - 'Checkerboard Cave': (0x126, 0x200), - 'Mini Moldorm Cave - Far Left': (0x123, 0x10), - 'Mini Moldorm Cave - Left': (0x123, 0x20), - 'Mini Moldorm Cave - Right': (0x123, 0x40), - 'Mini Moldorm Cave - Far Right': (0x123, 0x80), - 'Mini Moldorm Cave - Generous Guy': (0x123, 0x400), - 'Ice Rod Cave': (0x120, 0x10), - 'Bonk Rock Cave': (0x124, 0x10), - 'Desert Palace - Big Chest': (0x73, 0x10), - 'Desert Palace - Torch': (0x73, 0x400), - 'Desert Palace - Map Chest': (0x74, 0x10), - 'Desert Palace - Compass Chest': (0x85, 0x10), - 'Desert Palace - Big Key Chest': (0x75, 0x10), - 'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400), - 'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400), - 'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400), - 'Desert Palace - Boss': (0x33, 0x800), - 'Eastern Palace - Compass Chest': (0xa8, 0x10), - 'Eastern Palace - Big Chest': (0xa9, 0x10), - 'Eastern Palace - Dark Square Pot Key': (0xba, 0x400), - 'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400), - 'Eastern Palace - Cannonball Chest': (0xb9, 0x10), - 'Eastern Palace - Big Key Chest': (0xb8, 0x10), - 'Eastern Palace - Map Chest': (0xaa, 0x10), - 'Eastern Palace - Boss': (0xc8, 0x800), - 'Hyrule Castle - Boomerang Chest': (0x71, 0x10), - 'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400), - 'Hyrule Castle - Map Chest': (0x72, 0x10), - 'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400), - "Hyrule Castle - Zelda's Chest": (0x80, 0x10), - 'Hyrule Castle - Big Key Drop': (0x80, 0x400), - 'Sewers - Dark Cross': (0x32, 0x10), - 'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400), - 'Sewers - Secret Room - Left': (0x11, 0x10), - 'Sewers - Secret Room - Middle': (0x11, 0x20), - 'Sewers - Secret Room - Right': (0x11, 0x40), - 'Sanctuary': (0x12, 0x10), - 'Castle Tower - Room 03': (0xe0, 0x10), - 'Castle Tower - Dark Maze': (0xd0, 0x10), - 'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400), - 'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400), - 'Spectacle Rock Cave': (0xea, 0x400), - 'Paradox Cave Lower - Far Left': (0xef, 0x10), - 'Paradox Cave Lower - Left': (0xef, 0x20), - 'Paradox Cave Lower - Right': (0xef, 0x40), - 'Paradox Cave Lower - Far Right': (0xef, 0x80), - 'Paradox Cave Lower - Middle': (0xef, 0x100), - 'Paradox Cave Upper - Left': (0xff, 0x10), - 'Paradox Cave Upper - Right': (0xff, 0x20), - 'Spiral Cave': (0xfe, 0x10), - 'Tower of Hera - Basement Cage': (0x87, 0x400), - 'Tower of Hera - Map Chest': (0x77, 0x10), - 'Tower of Hera - Big Key Chest': (0x87, 0x10), - 'Tower of Hera - Compass Chest': (0x27, 0x20), - 'Tower of Hera - Big Chest': (0x27, 0x10), - 'Tower of Hera - Boss': (0x7, 0x800), - 'Hype Cave - Top': (0x11e, 0x10), - 'Hype Cave - Middle Right': (0x11e, 0x20), - 'Hype Cave - Middle Left': (0x11e, 0x40), - 'Hype Cave - Bottom': (0x11e, 0x80), - 'Hype Cave - Generous Guy': (0x11e, 0x400), - 'Peg Cave': (0x127, 0x400), - 'Pyramid Fairy - Left': (0x116, 0x10), - 'Pyramid Fairy - Right': (0x116, 0x20), - 'Brewery': (0x106, 0x10), - 'C-Shaped House': (0x11c, 0x10), - 'Chest Game': (0x106, 0x400), - 'Mire Shed - Left': (0x10d, 0x10), - 'Mire Shed - Right': (0x10d, 0x20), - 'Superbunny Cave - Top': (0xf8, 0x10), - 'Superbunny Cave - Bottom': (0xf8, 0x20), - 'Spike Cave': (0x117, 0x10), - 'Hookshot Cave - Top Right': (0x3c, 0x10), - 'Hookshot Cave - Top Left': (0x3c, 0x20), - 'Hookshot Cave - Bottom Right': (0x3c, 0x80), - 'Hookshot Cave - Bottom Left': (0x3c, 0x40), - 'Mimic Cave': (0x10c, 0x10), - 'Swamp Palace - Entrance': (0x28, 0x10), - 'Swamp Palace - Map Chest': (0x37, 0x10), - 'Swamp Palace - Pot Row Pot Key': (0x38, 0x400), - 'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400), - 'Swamp Palace - Hookshot Pot Key': (0x36, 0x400), - 'Swamp Palace - Big Chest': (0x36, 0x10), - 'Swamp Palace - Compass Chest': (0x46, 0x10), - 'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400), - 'Swamp Palace - Big Key Chest': (0x35, 0x10), - 'Swamp Palace - West Chest': (0x34, 0x10), - 'Swamp Palace - Flooded Room - Left': (0x76, 0x10), - 'Swamp Palace - Flooded Room - Right': (0x76, 0x20), - 'Swamp Palace - Waterfall Room': (0x66, 0x10), - 'Swamp Palace - Waterway Pot Key': (0x16, 0x400), - 'Swamp Palace - Boss': (0x6, 0x800), - "Thieves' Town - Big Key Chest": (0xdb, 0x20), - "Thieves' Town - Map Chest": (0xdb, 0x10), - "Thieves' Town - Compass Chest": (0xdc, 0x10), - "Thieves' Town - Ambush Chest": (0xcb, 0x10), - "Thieves' Town - Hallway Pot Key": (0xbc, 0x400), - "Thieves' Town - Spike Switch Pot Key": (0xab, 0x400), - "Thieves' Town - Attic": (0x65, 0x10), - "Thieves' Town - Big Chest": (0x44, 0x10), - "Thieves' Town - Blind's Cell": (0x45, 0x10), - "Thieves' Town - Boss": (0xac, 0x800), - 'Skull Woods - Compass Chest': (0x67, 0x10), - 'Skull Woods - Map Chest': (0x58, 0x20), - 'Skull Woods - Big Chest': (0x58, 0x10), - 'Skull Woods - Pot Prison': (0x57, 0x20), - 'Skull Woods - Pinball Room': (0x68, 0x10), - 'Skull Woods - Big Key Chest': (0x57, 0x10), - 'Skull Woods - West Lobby Pot Key': (0x56, 0x400), - 'Skull Woods - Bridge Room': (0x59, 0x10), - 'Skull Woods - Spike Corner Key Drop': (0x39, 0x400), - 'Skull Woods - Boss': (0x29, 0x800), - 'Ice Palace - Jelly Key Drop': (0x0e, 0x400), - 'Ice Palace - Compass Chest': (0x2e, 0x10), - 'Ice Palace - Conveyor Key Drop': (0x3e, 0x400), - 'Ice Palace - Freezor Chest': (0x7e, 0x10), - 'Ice Palace - Big Chest': (0x9e, 0x10), - 'Ice Palace - Iced T Room': (0xae, 0x10), - 'Ice Palace - Many Pots Pot Key': (0x9f, 0x400), - 'Ice Palace - Spike Room': (0x5f, 0x10), - 'Ice Palace - Big Key Chest': (0x1f, 0x10), - 'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400), - 'Ice Palace - Map Chest': (0x3f, 0x10), - 'Ice Palace - Boss': (0xde, 0x800), - 'Misery Mire - Big Chest': (0xc3, 0x10), - 'Misery Mire - Map Chest': (0xc3, 0x20), - 'Misery Mire - Main Lobby': (0xc2, 0x10), - 'Misery Mire - Bridge Chest': (0xa2, 0x10), - 'Misery Mire - Spikes Pot Key': (0xb3, 0x400), - 'Misery Mire - Spike Chest': (0xb3, 0x10), - 'Misery Mire - Fishbone Pot Key': (0xa1, 0x400), - 'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400), - 'Misery Mire - Compass Chest': (0xc1, 0x10), - 'Misery Mire - Big Key Chest': (0xd1, 0x10), - 'Misery Mire - Boss': (0x90, 0x800), - 'Turtle Rock - Compass Chest': (0xd6, 0x10), - 'Turtle Rock - Roller Room - Left': (0xb7, 0x10), - 'Turtle Rock - Roller Room - Right': (0xb7, 0x20), - 'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400), - 'Turtle Rock - Chain Chomps': (0xb6, 0x10), - 'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400), - 'Turtle Rock - Big Key Chest': (0x14, 0x10), - 'Turtle Rock - Big Chest': (0x24, 0x10), - 'Turtle Rock - Crystaroller Room': (0x4, 0x10), - 'Turtle Rock - Eye Bridge - Bottom Left': (0xd5, 0x80), - 'Turtle Rock - Eye Bridge - Bottom Right': (0xd5, 0x40), - 'Turtle Rock - Eye Bridge - Top Left': (0xd5, 0x20), - 'Turtle Rock - Eye Bridge - Top Right': (0xd5, 0x10), - 'Turtle Rock - Boss': (0xa4, 0x800), - 'Palace of Darkness - Shooter Room': (0x9, 0x10), - 'Palace of Darkness - The Arena - Bridge': (0x2a, 0x20), - 'Palace of Darkness - Stalfos Basement': (0xa, 0x10), - 'Palace of Darkness - Big Key Chest': (0x3a, 0x10), - 'Palace of Darkness - The Arena - Ledge': (0x2a, 0x10), - 'Palace of Darkness - Map Chest': (0x2b, 0x10), - 'Palace of Darkness - Compass Chest': (0x1a, 0x20), - 'Palace of Darkness - Dark Basement - Left': (0x6a, 0x10), - 'Palace of Darkness - Dark Basement - Right': (0x6a, 0x20), - 'Palace of Darkness - Dark Maze - Top': (0x19, 0x10), - 'Palace of Darkness - Dark Maze - Bottom': (0x19, 0x20), - 'Palace of Darkness - Big Chest': (0x1a, 0x10), - 'Palace of Darkness - Harmless Hellway': (0x1a, 0x40), - 'Palace of Darkness - Boss': (0x5a, 0x800), - 'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400), - "Ganons Tower - Bob's Torch": (0x8c, 0x400), - 'Ganons Tower - Hope Room - Left': (0x8c, 0x20), - 'Ganons Tower - Hope Room - Right': (0x8c, 0x40), - 'Ganons Tower - Tile Room': (0x8d, 0x10), - 'Ganons Tower - Compass Room - Top Left': (0x9d, 0x10), - 'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20), - 'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40), - 'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80), - 'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400), - 'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10), - 'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20), - 'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40), - 'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80), - 'Ganons Tower - Map Chest': (0x8b, 0x10), - 'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400), - 'Ganons Tower - Firesnake Room': (0x7d, 0x10), - 'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10), - 'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20), - 'Ganons Tower - Randomizer Room - Bottom Left': (0x7c, 0x40), - 'Ganons Tower - Randomizer Room - Bottom Right': (0x7c, 0x80), - "Ganons Tower - Bob's Chest": (0x8c, 0x80), - 'Ganons Tower - Big Chest': (0x8c, 0x10), - 'Ganons Tower - Big Key Room - Left': (0x1c, 0x20), - 'Ganons Tower - Big Key Room - Right': (0x1c, 0x40), - 'Ganons Tower - Big Key Chest': (0x1c, 0x10), - 'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10), - 'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20), - 'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400), - 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), - 'Ganons Tower - Validation Chest': (0x4d, 0x10)} - -boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss', - 'Desert Palace - Boss', - 'Tower of Hera - Boss', - 'Palace of Darkness - Boss', - 'Swamp Palace - Boss', - 'Skull Woods - Boss', - "Thieves' Town - Boss", - 'Ice Palace - Boss', - 'Misery Mire - Boss', - 'Turtle Rock - Boss', - 'Sahasrahla'}} - -location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()} - -location_table_npc = {'Mushroom': 0x1000, - 'King Zora': 0x2, - 'Sahasrahla': 0x10, - 'Blacksmith': 0x400, - 'Magic Bat': 0x8000, - 'Sick Kid': 0x4, - 'Library': 0x80, - 'Potion Shop': 0x2000, - 'Old Man': 0x1, - 'Ether Tablet': 0x100, - 'Catfish': 0x20, - 'Stumpy': 0x8, - 'Bombos Tablet': 0x200} - -location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()} - -location_table_ow = {'Flute Spot': 0x2a, - 'Sunken Treasure': 0x3b, - "Zora's Ledge": 0x81, - 'Lake Hylia Island': 0x35, - 'Maze Race': 0x28, - 'Desert Ledge': 0x30, - 'Master Sword Pedestal': 0x80, - 'Spectacle Rock': 0x3, - 'Pyramid': 0x5b, - 'Digging Game': 0x68, - 'Bumper Cave Ledge': 0x4a, - 'Floating Island': 0x5} - -location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()} - -location_table_misc = {'Bottle Merchant': (0x3c9, 0x2), - 'Purple Chest': (0x3c9, 0x10), - "Link's Uncle": (0x3c6, 0x1), - 'Hobo': (0x3c9, 0x1)} - -location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()} +_global_snes_reconnect_delay = 5 class SNESState(enum.IntEnum): @@ -607,13 +269,13 @@ class SNESState(enum.IntEnum): SNES_ATTACHED = 3 -def launch_sni(): - sni_path = Utils.get_options()["lttp_options"]["sni"] +def launch_sni() -> None: + sni_path = Utils.get_options()["sni_options"]["sni_path"] if not os.path.isdir(sni_path): sni_path = Utils.local_path(sni_path) if os.path.isdir(sni_path): - dir_entry: os.DirEntry + dir_entry: "os.DirEntry[str]" for dir_entry in os.scandir(sni_path): if dir_entry.is_file(): lower_file = dir_entry.name.lower() @@ -641,13 +303,13 @@ def launch_sni(): f"please start it yourself if it is not running") -async def _snes_connect(ctx: Context, address: str): +async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol: address = f"ws://{address}" if "://" not in address else address snes_logger.info("Connecting to SNI at %s ..." % address) - seen_problems = set() - while 1: + seen_problems: typing.Set[str] = set() + while True: try: - snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None) + snes_socket = await websockets_connect(address, ping_timeout=None, ping_interval=None) except Exception as e: problem = "%s" % e # only tell the user about new problems, otherwise silently lay in wait for a working connection @@ -664,15 +326,24 @@ async def _snes_connect(ctx: Context, address: str): return snes_socket -async def get_snes_devices(ctx: Context) -> typing.List[str]: +class SNESRequest(typing.TypedDict): + Opcode: str + Space: str + Operands: typing.List[str] + # TODO: When Python 3.11 is the lowest version supported, `Operands` can use `typing.NotRequired` (pep-0655) + # Then the `Operands` key doesn't need to be given for opcodes that don't use it. + + +async def get_snes_devices(ctx: SNIContext) -> typing.List[str]: socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll - DeviceList_Request = { + DeviceList_Request: SNESRequest = { "Opcode": "DeviceList", - "Space": "SNES" + "Space": "SNES", + "Operands": [] } await socket.send(dumps(DeviceList_Request)) - reply: dict = loads(await socket.recv()) + reply: typing.Dict[str, typing.Any] = loads(await socket.recv()) devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else [] if not devices: @@ -688,7 +359,7 @@ async def get_snes_devices(ctx: Context) -> typing.List[str]: return sorted(devices) -async def verify_snes_app(socket): +async def verify_snes_app(socket: WebSocketClientProtocol) -> None: AppVersion_Request = { "Opcode": "AppVersion", } @@ -699,8 +370,8 @@ async def verify_snes_app(socket): snes_logger.warning(f"Warning: Did not find SNI as the endpoint, instead {app} was found.") -async def snes_connect(ctx: Context, address, deviceIndex=-1): - global SNES_RECONNECT_DELAY +async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) -> None: + global _global_snes_reconnect_delay if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED: if ctx.rom: snes_logger.error('Already connected to SNES, with rom loaded.') @@ -722,6 +393,7 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): if device_count == 1: device = devices[0] elif ctx.snes_reconnect_address: + assert ctx.snes_attached_device if ctx.snes_attached_device[1] in devices: device = ctx.snes_attached_device[1] else: @@ -746,7 +418,7 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): snes_logger.info("Attaching to " + device) - Attach_Request = { + Attach_Request: SNESRequest = { "Opcode": "Attach", "Space": "SNES", "Operands": [device] @@ -770,35 +442,37 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): if not ctx.snes_reconnect_address: snes_logger.error("Error connecting to snes (%s)" % e) else: - snes_logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s") + snes_logger.error(f"Error connecting to snes, attempt again in {_global_snes_reconnect_delay}s") asyncio.create_task(snes_autoreconnect(ctx)) - SNES_RECONNECT_DELAY *= 2 + _global_snes_reconnect_delay *= 2 else: - SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay + _global_snes_reconnect_delay = ctx.starting_reconnect_delay snes_logger.info(f"Attached to {device}") -async def snes_disconnect(ctx: Context): +async def snes_disconnect(ctx: SNIContext) -> None: if ctx.snes_socket: if not ctx.snes_socket.closed: await ctx.snes_socket.close() ctx.snes_socket = None -async def snes_autoreconnect(ctx: Context): - await asyncio.sleep(SNES_RECONNECT_DELAY) +async def snes_autoreconnect(ctx: SNIContext) -> None: + await asyncio.sleep(_global_snes_reconnect_delay) if ctx.snes_reconnect_address and ctx.snes_socket is None: await snes_connect(ctx, ctx.snes_reconnect_address) -async def snes_recv_loop(ctx: Context): +async def snes_recv_loop(ctx: SNIContext) -> None: try: + if ctx.snes_socket is None: + raise Exception("invalid context state - snes_socket not connected") async for msg in ctx.snes_socket: - ctx.snes_recv_queue.put_nowait(msg) + ctx.snes_recv_queue.put_nowait(typing.cast(bytes, msg)) snes_logger.warning("Snes disconnected") except Exception as e: - if not isinstance(e, websockets.WebSocketException): + if not isinstance(e, WebSocketException): snes_logger.exception(e) snes_logger.error("Lost connection to the snes, type /snes to reconnect") finally: @@ -813,28 +487,33 @@ async def snes_recv_loop(ctx: Context): ctx.rom = None if ctx.snes_reconnect_address: - snes_logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s") + snes_logger.info(f"...reconnecting in {_global_snes_reconnect_delay}s") asyncio.create_task(snes_autoreconnect(ctx)) -async def snes_read(ctx: Context, address, size): +async def snes_read(ctx: SNIContext, address: int, size: int) -> typing.Optional[bytes]: try: await ctx.snes_request_lock.acquire() - if ctx.snes_state != SNESState.SNES_ATTACHED or ctx.snes_socket is None or not ctx.snes_socket.open or ctx.snes_socket.closed: + if ( + ctx.snes_state != SNESState.SNES_ATTACHED or + ctx.snes_socket is None or + not ctx.snes_socket.open or + ctx.snes_socket.closed + ): return None - GetAddress_Request = { + GetAddress_Request: SNESRequest = { "Opcode": "GetAddress", "Space": "SNES", "Operands": [hex(address)[2:], hex(size)[2:]] } try: await ctx.snes_socket.send(dumps(GetAddress_Request)) - except websockets.ConnectionClosed: + except ConnectionClosed: return None - data = bytes() + data: bytes = bytes() while len(data) < size: try: data += await asyncio.wait_for(ctx.snes_recv_queue.get(), 5) @@ -855,7 +534,7 @@ async def snes_read(ctx: Context, address, size): ctx.snes_request_lock.release() -async def snes_write(ctx: Context, write_list): +async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int, bytes]]) -> bool: try: await ctx.snes_request_lock.acquire() @@ -863,16 +542,18 @@ async def snes_write(ctx: Context, write_list): not ctx.snes_socket.open or ctx.snes_socket.closed: return False - PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} + PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} try: for address, data in write_list: PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] + # REVIEW: above: `if snes_socket is None: return False` + # Does it need to be checked again? if ctx.snes_socket is not None: await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(data) else: snes_logger.warning(f"Could not send data to SNES: {data}") - except websockets.ConnectionClosed: + except ConnectionClosed: return False return True @@ -880,7 +561,7 @@ async def snes_write(ctx: Context, write_list): ctx.snes_request_lock.release() -def snes_buffered_write(ctx: Context, address, data): +def snes_buffered_write(ctx: SNIContext, address: int, data: bytes) -> None: if ctx.snes_write_buffer and (ctx.snes_write_buffer[-1][0] + len(ctx.snes_write_buffer[-1][1])) == address: # append to existing write command, bundling them ctx.snes_write_buffer[-1] = (ctx.snes_write_buffer[-1][0], ctx.snes_write_buffer[-1][1] + data) @@ -888,7 +569,7 @@ def snes_buffered_write(ctx: Context, address, data): ctx.snes_write_buffer.append((address, data)) -async def snes_flush_writes(ctx: Context): +async def snes_flush_writes(ctx: SNIContext) -> None: if not ctx.snes_write_buffer: return @@ -897,142 +578,7 @@ async def snes_flush_writes(ctx: Context): await snes_write(ctx, writes) -async def track_locations(ctx: Context, roomid, roomdata): - new_locations = [] - - def new_check(location_id): - new_locations.append(location_id) - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info( - f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - - try: - shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN) - shop_data_changed = False - shop_data = list(shop_data) - for cnt, b in enumerate(shop_data): - location = Shops.SHOP_ID_START + cnt - if int(b) and location not in ctx.locations_checked: - new_check(location) - if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \ - and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot: - if not int(b): - shop_data[cnt] += 1 - shop_data_changed = True - if shop_data_changed: - snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data)) - except Exception as e: - snes_logger.info(f"Exception: {e}") - - for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items(): - try: - if location_id not in ctx.locations_checked and loc_roomid == roomid and \ - (roomdata << 4) & loc_mask != 0: - new_check(location_id) - except Exception as e: - snes_logger.exception(f"Exception: {e}") - - uw_begin = 0x129 - ow_end = uw_end = 0 - uw_unchecked = {} - uw_checked = {} - for location, (roomid, mask) in location_table_uw.items(): - location_id = Regions.lookup_name_to_id[location] - if location_id not in ctx.locations_checked: - uw_unchecked[location_id] = (roomid, mask) - uw_begin = min(uw_begin, roomid) - uw_end = max(uw_end, roomid + 1) - if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ - and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - uw_begin = min(uw_begin, roomid) - uw_end = max(uw_end, roomid + 1) - uw_checked[location_id] = (roomid, mask) - - if uw_begin < uw_end: - uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2) - if uw_data is not None: - for location_id, (roomid, mask) in uw_unchecked.items(): - offset = (roomid - uw_begin) * 2 - roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) - if roomdata & mask != 0: - new_check(location_id) - if uw_checked: - uw_data = list(uw_data) - for location_id, (roomid, mask) in uw_checked.items(): - offset = (roomid - uw_begin) * 2 - roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) - roomdata |= mask - uw_data[offset] = roomdata & 0xFF - uw_data[offset + 1] = roomdata >> 8 - snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data)) - - ow_begin = 0x82 - ow_unchecked = {} - ow_checked = {} - for location_id, screenid in location_table_ow_id.items(): - if location_id not in ctx.locations_checked: - ow_unchecked[location_id] = screenid - ow_begin = min(ow_begin, screenid) - ow_end = max(ow_end, screenid + 1) - if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - ow_checked[location_id] = screenid - - if ow_begin < ow_end: - ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin) - if ow_data is not None: - for location_id, screenid in ow_unchecked.items(): - if ow_data[screenid - ow_begin] & 0x40 != 0: - new_check(location_id) - if ow_checked: - ow_data = list(ow_data) - for location_id, screenid in ow_checked.items(): - ow_data[screenid - ow_begin] |= 0x40 - snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data)) - - if not ctx.locations_checked.issuperset(location_table_npc_id): - npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2) - if npc_data is not None: - npc_value_changed = False - npc_value = npc_data[0] | (npc_data[1] << 8) - for location_id, mask in location_table_npc_id.items(): - if npc_value & mask != 0 and location_id not in ctx.locations_checked: - new_check(location_id) - if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ - and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - npc_value |= mask - npc_value_changed = True - if npc_value_changed: - npc_data = bytes([npc_value & 0xFF, npc_value >> 8]) - snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data) - - if not ctx.locations_checked.issuperset(location_table_misc_id): - misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4) - if misc_data is not None: - misc_data = list(misc_data) - misc_data_changed = False - for location_id, (offset, mask) in location_table_misc_id.items(): - assert (0x3c6 <= offset <= 0x3c9) - if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: - new_check(location_id) - if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \ - and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot: - misc_data_changed = True - misc_data[offset - 0x3c6] |= mask - if misc_data_changed: - snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data)) - - - if new_locations: - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) - await snes_flush_writes(ctx) - - -async def game_watcher(ctx: Context): - prev_game_timer = 0 +async def game_watcher(ctx: SNIContext) -> None: perf_counter = time.perf_counter() while not ctx.exit_event.is_set(): try: @@ -1041,54 +587,24 @@ async def game_watcher(ctx: Context): pass ctx.watcher_event.clear() - if not ctx.rom: + if not ctx.rom or not ctx.client_handler: ctx.finished_game = False ctx.death_link_allow_survive = False - from worlds.dkc3.Client import dkc3_rom_init - init_handled = await dkc3_rom_init(ctx) - if not init_handled: - from worlds.smw.Client import smw_rom_init - init_handled = await smw_rom_init(ctx) - if not init_handled: - game_name = await snes_read(ctx, SM_ROMNAME_START, 5) - if game_name is None: - continue - elif game_name[:2] == b"SM": - ctx.game = GAME_SM - # versions lower than 0.3.0 dont have item handling flag nor remote item support - romVersion = int(game_name[2:5].decode('UTF-8')) - if romVersion < 30: - ctx.items_handling = 0b001 # full local - else: - item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1) - ctx.items_handling = 0b001 if item_handling is None else item_handling[0] - else: - game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3) - if game_name == b"ZSM": - ctx.game = GAME_SMZ3 - ctx.items_handling = 0b101 # local items and remote start inventory - else: - ctx.game = GAME_ALTTP - ctx.items_handling = 0b001 # full local + from worlds.AutoSNIClient import AutoSNIClientRegister + ctx.client_handler = await AutoSNIClientRegister.get_handler(ctx) - rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE) - if rom is None or rom == bytes([0] * ROMNAME_SIZE): - continue + if not ctx.client_handler: + continue - ctx.rom = rom - if ctx.game != GAME_SMZ3: - death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else - SM_DEATH_LINK_ACTIVE_ADDR, 1) - if death_link: - ctx.allow_collect = bool(death_link[0] & 0b100) - ctx.death_link_allow_survive = bool(death_link[0] & 0b10) - await ctx.update_death_link(bool(death_link[0] & 0b1)) - if not ctx.prev_rom or ctx.prev_rom != ctx.rom: - ctx.locations_checked = set() - ctx.locations_scouted = set() - ctx.locations_info = {} - ctx.prev_rom = ctx.rom + if not ctx.rom: + continue + + if not ctx.prev_rom or ctx.prev_rom != ctx.rom: + ctx.locations_checked = set() + ctx.locations_scouted = set() + ctx.locations_info = {} + ctx.prev_rom = ctx.rom if ctx.awaiting_rom: await ctx.server_auth(False) @@ -1096,234 +612,40 @@ async def game_watcher(ctx: Context): snes_logger.warning("ROM detected but no active multiworld server connection. " + "Connect using command: /connect server:port") - if ctx.auth and ctx.auth != ctx.rom: + if not ctx.client_handler: + continue + + rom_validated = await ctx.client_handler.validate_rom(ctx) + + if not rom_validated or (ctx.auth and ctx.auth != ctx.rom): snes_logger.warning("ROM change detected, please reconnect to the multiworld server") await ctx.disconnect() + ctx.client_handler = None + ctx.rom = None + ctx.command_processor(ctx).connect_to_snes() + continue - if ctx.game == GAME_ALTTP: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): - currently_dead = gamemode[0] in DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) + delay = 7 if ctx.slow_mode else 0 + if time.perf_counter() - perf_counter < delay: + continue - gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) - game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) - if gamemode is None or gameend is None or game_timer is None or \ - (gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES): - continue + perf_counter = time.perf_counter() - delay = 7 if ctx.slow_mode else 2 - if gameend[0]: - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - - if time.perf_counter() - perf_counter < delay: - continue - else: - perf_counter = time.perf_counter() - else: - game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24) - if abs(game_timer - prev_game_timer) < (delay * 60): - continue - else: - prev_game_timer = game_timer - - if gamemode in ENDGAME_MODES: # triforce room and credits - continue - - data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] - roomid = data[4] | (data[5] << 8) - roomdata = data[6] - scout_location = data[7] - - if recv_index < len(ctx.items_received) and recv_item == 0: - item = ctx.items_received[recv_index] - recv_index += 1 - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), - color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], recv_index, len(ctx.items_received))) - - snes_buffered_write(ctx, RECV_PROGRESS_ADDR, - bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - snes_buffered_write(ctx, RECV_ITEM_ADDR, - bytes([item.item])) - snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, - bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0])) - if scout_location > 0 and scout_location in ctx.locations_info: - snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, - bytes([scout_location])) - snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, - bytes([ctx.locations_info[scout_location].item])) - snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, - bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)])) - - await snes_flush_writes(ctx) - - if scout_location > 0 and scout_location not in ctx.locations_scouted: - ctx.locations_scouted.add(scout_location) - await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) - await track_locations(ctx, roomid, roomdata) - elif ctx.game == GAME_SM: - if ctx.server is None or ctx.slot is None: - # not successfully connected to a multiworld server, cannot process the game sending items - continue - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): - currently_dead = gamemode[0] in SM_DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) - if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES: - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - continue - - data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT - - while (recv_index < recv_item): - itemAdress = recv_index * 8 - message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8) - # worldId = message[0] | (message[1] << 8) # unused - # itemId = message[2] | (message[3] << 8) # unused - itemIndex = (message[4] | (message[5] << 8)) >> 3 - - recv_index += 1 - snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, - bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - - from worlds.sm import locations_start_id - location_id = locations_start_id + itemIndex - - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info( - f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) - - data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) - if data is None: - continue - - itemOutPtr = data[0] | (data[1] << 8) - - from worlds.sm import items_start_id - from worlds.sm import locations_start_id - if itemOutPtr < len(ctx.items_received): - item = ctx.items_received[itemOutPtr] - itemId = item.item - items_start_id - if bool(ctx.items_handling & 0b010): - locationId = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF - else: - locationId = 0x00 #backward compat - - playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 - snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes( - [playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF])) - itemOutPtr += 1 - snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, - bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), - color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) - await snes_flush_writes(ctx) - elif ctx.game == GAME_SMZ3: - if ctx.server is None or ctx.slot is None: - # not successfully connected to a multiworld server, cannot process the game sending items - continue - currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) - if (currentGame is not None): - if (currentGame[0] != 0): - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - endGameModes = SM_ENDGAME_MODES - else: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - endGameModes = ENDGAME_MODES - - if gamemode is not None and (gamemode[0] in endGameModes): - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - continue - - data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] | (data[3] << 8) - - while (recv_index < recv_item): - itemAdress = recv_index * 8 - message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8) - # worldId = message[0] | (message[1] << 8) # unused - # itemId = message[2] | (message[3] << 8) # unused - isZ3Item = ((message[5] & 0x80) != 0) - maskedPart = (message[5] & 0x7F) if isZ3Item else message[5] - itemIndex = ((message[4] | (maskedPart << 8)) >> 3) + (256 if isZ3Item else 0) - - recv_index += 1 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - - from worlds.smz3.TotalSMZ3.Location import locations_start_id - from worlds.smz3 import convertLocSMZ3IDToAPID - location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex) - - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) - - data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4) - if data is None: - continue - - # recv_itemOutPtr = data[0] | (data[1] << 8) # unused - itemOutPtr = data[2] | (data[3] << 8) - - from worlds.smz3.TotalSMZ3.Item import items_start_id - if itemOutPtr < len(ctx.items_received): - item = ctx.items_received[itemOutPtr] - itemId = item.item - items_start_id - - playerID = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF])) - itemOutPtr += 1 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) - await snes_flush_writes(ctx) - elif ctx.game == GAME_DKC3: - from worlds.dkc3.Client import dkc3_game_watcher - await dkc3_game_watcher(ctx) - elif ctx.game == GAME_SMW: - from worlds.smw.Client import smw_game_watcher - await smw_game_watcher(ctx) + await ctx.client_handler.game_watcher(ctx) -async def run_game(romfile): - auto_start = Utils.get_options()["lttp_options"].get("rom_start", True) +async def run_game(romfile: str) -> None: + auto_start = typing.cast(typing.Union[bool, str], + Utils.get_options()["sni_options"].get("snes_rom_start", True)) if auto_start is True: import webbrowser webbrowser.open(romfile) - elif os.path.isfile(auto_start): + elif isinstance(auto_start, str) and os.path.isfile(auto_start): subprocess.Popen([auto_start, romfile], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -async def main(): +async def main() -> None: multiprocessing.freeze_support() parser = get_base_parser() parser.add_argument('diff_file', default="", type=str, nargs="?", @@ -1350,12 +672,13 @@ async def main(): time.sleep(3) sys.exit() elif args.diff_file.endswith(".aplttp"): + from worlds.alttp.Client import get_alttp_settings adjustedromfile, adjusted = get_alttp_settings(romfile) asyncio.create_task(run_game(adjustedromfile if adjusted else romfile)) else: asyncio.create_task(run_game(romfile)) - ctx = Context(args.snes, args.connect, args.password) + ctx = SNIContext(args.snes, args.connect, args.password) if ctx.server_task is None: ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") @@ -1376,132 +699,6 @@ async def main(): await ctx.shutdown() -def get_alttp_settings(romfile: str): - lastSettings = Utils.get_adjuster_settings(GAME_ALTTP) - adjustedromfile = '' - if lastSettings: - choice = 'no' - if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply: - - whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap", - "uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes", - "reduceflashing", "deathlink", "allowcollect"} - printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist} - if hasattr(lastSettings, "sprite_pool"): - sprite_pool = {} - for sprite in lastSettings.sprite_pool: - if sprite in sprite_pool: - sprite_pool[sprite] += 1 - else: - sprite_pool[sprite] = 1 - if sprite_pool: - printed_options["sprite_pool"] = sprite_pool - import pprint - - if gui_enabled: - - try: - from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button - applyPromptWindow = Tk() - except Exception as e: - logging.error('Could not load tkinter, which is likely not installed.') - return '', False - - applyPromptWindow.resizable(False, False) - applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick()) - logo = PhotoImage(file=Utils.local_path('data', 'icon.png')) - applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo) - applyPromptWindow.wm_title("Last adjuster settings LttP") - - label = LabelFrame(applyPromptWindow, - text='Last used adjuster settings were found. Would you like to apply these?') - label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5) - label.grid_columnconfigure(0, weight=1) - label.grid_columnconfigure(1, weight=1) - label.grid_columnconfigure(2, weight=1) - label.grid_columnconfigure(3, weight=1) - - def onButtonClick(answer: str = 'no'): - setattr(onButtonClick, 'choice', answer) - applyPromptWindow.destroy() - - framedOptions = Frame(label) - framedOptions.grid(column=0, columnspan=4, row=0) - framedOptions.grid_columnconfigure(0, weight=1) - framedOptions.grid_columnconfigure(1, weight=1) - framedOptions.grid_columnconfigure(2, weight=1) - curRow = 0 - curCol = 0 - for name, value in printed_options.items(): - Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5) - if (curCol == 2): - curRow += 1 - curCol = 0 - else: - curCol += 1 - - yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10) - yesButton.grid(column=0, row=1) - noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10) - noButton.grid(column=1, row=1) - alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10) - alwaysButton.grid(column=2, row=1) - neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10) - neverButton.grid(column=3, row=1) - - Utils.tkinter_center_window(applyPromptWindow) - applyPromptWindow.mainloop() - choice = getattr(onButtonClick, 'choice') - else: - choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n" - f"{pprint.pformat(printed_options)}\n" - f"Enter yes, no, always or never: ") - if choice and choice.startswith("y"): - choice = 'yes' - elif choice and "never" in choice: - choice = 'no' - lastSettings.auto_apply = 'never' - Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) - elif choice and "always" in choice: - choice = 'yes' - lastSettings.auto_apply = 'always' - Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) - else: - choice = 'no' - elif 'never' in lastSettings.auto_apply: - choice = 'no' - elif 'always' in lastSettings.auto_apply: - choice = 'yes' - - if 'yes' in choice: - from worlds.alttp.Rom import get_base_rom_path - lastSettings.rom = romfile - lastSettings.baserom = get_base_rom_path() - lastSettings.world = None - - if hasattr(lastSettings, "sprite_pool"): - from LttPAdjuster import AdjusterWorld - lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool")) - - adjusted = True - import LttPAdjuster - _, adjustedromfile = LttPAdjuster.adjust(lastSettings) - - if hasattr(lastSettings, "world"): - delattr(lastSettings, "world") - else: - adjusted = False - if adjusted: - try: - shutil.move(adjustedromfile, romfile) - adjustedromfile = romfile - except Exception as e: - logging.exception(e) - else: - adjusted = False - return adjustedromfile, adjusted - - if __name__ == '__main__': colorama.init() asyncio.run(main()) diff --git a/Utils.py b/Utils.py index d28834b698..64a028fc33 100644 --- a/Utils.py +++ b/Utils.py @@ -141,7 +141,7 @@ def user_path(*path: str) -> str: return os.path.join(user_path.cached_path, *path) -def output_path(*path: str): +def output_path(*path: str) -> str: if hasattr(output_path, 'cached_path'): return os.path.join(output_path.cached_path, *path) output_path.cached_path = user_path(get_options()["general_options"]["output_path"]) @@ -232,19 +232,18 @@ def get_default_options() -> OptionsType: "factorio_options": { "executable": os.path.join("factorio", "bin", "x64", "factorio"), }, + "sni_options": { + "sni": "SNI", + "snes_rom_start": True, + }, "sm_options": { "rom_file": "Super Metroid (JU).sfc", - "sni": "SNI", - "rom_start": True, }, "soe_options": { "rom_file": "Secret of Evermore (USA).sfc", }, "lttp_options": { "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", - "sni": "SNI", - "rom_start": True, - }, "server_options": { "host": None, @@ -287,13 +286,9 @@ def get_default_options() -> OptionsType: }, "dkc3_options": { "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", - "sni": "SNI", - "rom_start": True, }, "smw_options": { "rom_file": "Super Mario World (USA).sfc", - "sni": "SNI", - "rom_start": True, }, "zillion_options": { "rom_file": "Zillion (UE) [!].sms", diff --git a/host.yaml b/host.yaml index 2bb0e5ef5d..2c5a8e3e1d 100644 --- a/host.yaml +++ b/host.yaml @@ -82,24 +82,19 @@ generator: # List of options that can be plando'd. Can be combined, for example "bosses, items" # Available options: bosses, items, texts, connections plando_options: "bosses" +sni_options: + # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found + sni_path: "SNI" + # Set this to false to never autostart a rom (such as after patching) + # True for operating system default program + # Alternatively, a path to a program to open the .sfc file with + snes_rom_start: true lttp_options: # File name of the v1.0 J rom rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true sm_options: # File name of the v1.0 J rom rom_file: "Super Metroid (JU).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true factorio_options: executable: "factorio/bin/x64/factorio" # by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used. @@ -122,22 +117,12 @@ soe_options: rom_file: "Secret of Evermore (USA).sfc" ffr_options: display_msgs: true -smz3_options: - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true dkc3_options: # File name of the DKC3 US rom rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true +smw_options: + # File name of the SMW US rom + rom_file: "Super Mario World (USA).sfc" pokemon_rb_options: # File names of the Pokemon Red and Blue roms red_rom_file: "Pokemon Red (UE) [S][!].gb" @@ -146,15 +131,6 @@ pokemon_rb_options: # True for operating system default program # Alternatively, a path to a program to open the .gb file with rom_start: true -smw_options: - # File name of the SMW US rom - rom_file: "Super Mario World (USA).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true zillion_options: # File name of the Zillion US rom rom_file: "Zillion (UE) [!].sms" diff --git a/worlds/AutoSNIClient.py b/worlds/AutoSNIClient.py new file mode 100644 index 0000000000..a30dbbb46d --- /dev/null +++ b/worlds/AutoSNIClient.py @@ -0,0 +1,42 @@ + +from __future__ import annotations +import abc +from typing import TYPE_CHECKING, ClassVar, Dict, Tuple, Any, Optional + +if TYPE_CHECKING: + from SNIClient import SNIContext + + +class AutoSNIClientRegister(abc.ABCMeta): + game_handlers: ClassVar[Dict[str, SNIClient]] = {} + + def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoSNIClientRegister: + # construct class + new_class = super().__new__(cls, name, bases, dct) + if "game" in dct: + AutoSNIClientRegister.game_handlers[dct["game"]] = new_class() + return new_class + + @staticmethod + async def get_handler(ctx: SNIContext) -> Optional[SNIClient]: + for _game, handler in AutoSNIClientRegister.game_handlers.items(): + if await handler.validate_rom(ctx): + return handler + return None + + +class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister): + + @abc.abstractmethod + async def validate_rom(self, ctx: SNIContext) -> bool: + """ TODO: interface documentation here """ + ... + + @abc.abstractmethod + async def game_watcher(self, ctx: SNIContext) -> None: + """ TODO: interface documentation here """ + ... + + async def deathlink_kill_player(self, ctx: SNIContext) -> None: + """ override this with implementation to kill player """ + pass diff --git a/worlds/alttp/Client.py b/worlds/alttp/Client.py new file mode 100644 index 0000000000..b3a12a7ff8 --- /dev/null +++ b/worlds/alttp/Client.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +import logging +import asyncio +import shutil +import time + +import Utils + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient + +from worlds.alttp import Shops, Regions +from .Rom import ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +GAME_ALTTP = "A Link to the Past" + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +ROMNAME_START = SRAM_START + 0x2000 +ROMNAME_SIZE = 0x15 + +INGAME_MODES = {0x07, 0x09, 0x0b} +ENDGAME_MODES = {0x19, 0x1a} +DEATH_MODES = {0x12} + +SAVEDATA_START = WRAM_START + 0xF000 +SAVEDATA_SIZE = 0x500 + +RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes +RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte +RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte +ROOMID_ADDR = SAVEDATA_START + 0x4D4 # 2 bytes +ROOMDATA_ADDR = SAVEDATA_START + 0x4D6 # 1 byte +SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte +SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte +SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte +SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte +SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes +SHOP_LEN = (len(Shops.shop_table) * 3) + 5 + +DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte + +location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()]) + +location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), + "Blind's Hideout - Left": (0x11d, 0x20), + "Blind's Hideout - Right": (0x11d, 0x40), + "Blind's Hideout - Far Left": (0x11d, 0x80), + "Blind's Hideout - Far Right": (0x11d, 0x100), + 'Secret Passage': (0x55, 0x10), + 'Waterfall Fairy - Left': (0x114, 0x10), + 'Waterfall Fairy - Right': (0x114, 0x20), + "King's Tomb": (0x113, 0x10), + 'Floodgate Chest': (0x10b, 0x10), + "Link's House": (0x104, 0x10), + 'Kakariko Tavern': (0x103, 0x10), + 'Chicken House': (0x108, 0x10), + "Aginah's Cave": (0x10a, 0x10), + "Sahasrahla's Hut - Left": (0x105, 0x10), + "Sahasrahla's Hut - Middle": (0x105, 0x20), + "Sahasrahla's Hut - Right": (0x105, 0x40), + 'Kakariko Well - Top': (0x2f, 0x10), + 'Kakariko Well - Left': (0x2f, 0x20), + 'Kakariko Well - Middle': (0x2f, 0x40), + 'Kakariko Well - Right': (0x2f, 0x80), + 'Kakariko Well - Bottom': (0x2f, 0x100), + 'Lost Woods Hideout': (0xe1, 0x200), + 'Lumberjack Tree': (0xe2, 0x200), + 'Cave 45': (0x11b, 0x400), + 'Graveyard Cave': (0x11b, 0x200), + 'Checkerboard Cave': (0x126, 0x200), + 'Mini Moldorm Cave - Far Left': (0x123, 0x10), + 'Mini Moldorm Cave - Left': (0x123, 0x20), + 'Mini Moldorm Cave - Right': (0x123, 0x40), + 'Mini Moldorm Cave - Far Right': (0x123, 0x80), + 'Mini Moldorm Cave - Generous Guy': (0x123, 0x400), + 'Ice Rod Cave': (0x120, 0x10), + 'Bonk Rock Cave': (0x124, 0x10), + 'Desert Palace - Big Chest': (0x73, 0x10), + 'Desert Palace - Torch': (0x73, 0x400), + 'Desert Palace - Map Chest': (0x74, 0x10), + 'Desert Palace - Compass Chest': (0x85, 0x10), + 'Desert Palace - Big Key Chest': (0x75, 0x10), + 'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400), + 'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400), + 'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400), + 'Desert Palace - Boss': (0x33, 0x800), + 'Eastern Palace - Compass Chest': (0xa8, 0x10), + 'Eastern Palace - Big Chest': (0xa9, 0x10), + 'Eastern Palace - Dark Square Pot Key': (0xba, 0x400), + 'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400), + 'Eastern Palace - Cannonball Chest': (0xb9, 0x10), + 'Eastern Palace - Big Key Chest': (0xb8, 0x10), + 'Eastern Palace - Map Chest': (0xaa, 0x10), + 'Eastern Palace - Boss': (0xc8, 0x800), + 'Hyrule Castle - Boomerang Chest': (0x71, 0x10), + 'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400), + 'Hyrule Castle - Map Chest': (0x72, 0x10), + 'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400), + "Hyrule Castle - Zelda's Chest": (0x80, 0x10), + 'Hyrule Castle - Big Key Drop': (0x80, 0x400), + 'Sewers - Dark Cross': (0x32, 0x10), + 'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400), + 'Sewers - Secret Room - Left': (0x11, 0x10), + 'Sewers - Secret Room - Middle': (0x11, 0x20), + 'Sewers - Secret Room - Right': (0x11, 0x40), + 'Sanctuary': (0x12, 0x10), + 'Castle Tower - Room 03': (0xe0, 0x10), + 'Castle Tower - Dark Maze': (0xd0, 0x10), + 'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400), + 'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400), + 'Spectacle Rock Cave': (0xea, 0x400), + 'Paradox Cave Lower - Far Left': (0xef, 0x10), + 'Paradox Cave Lower - Left': (0xef, 0x20), + 'Paradox Cave Lower - Right': (0xef, 0x40), + 'Paradox Cave Lower - Far Right': (0xef, 0x80), + 'Paradox Cave Lower - Middle': (0xef, 0x100), + 'Paradox Cave Upper - Left': (0xff, 0x10), + 'Paradox Cave Upper - Right': (0xff, 0x20), + 'Spiral Cave': (0xfe, 0x10), + 'Tower of Hera - Basement Cage': (0x87, 0x400), + 'Tower of Hera - Map Chest': (0x77, 0x10), + 'Tower of Hera - Big Key Chest': (0x87, 0x10), + 'Tower of Hera - Compass Chest': (0x27, 0x20), + 'Tower of Hera - Big Chest': (0x27, 0x10), + 'Tower of Hera - Boss': (0x7, 0x800), + 'Hype Cave - Top': (0x11e, 0x10), + 'Hype Cave - Middle Right': (0x11e, 0x20), + 'Hype Cave - Middle Left': (0x11e, 0x40), + 'Hype Cave - Bottom': (0x11e, 0x80), + 'Hype Cave - Generous Guy': (0x11e, 0x400), + 'Peg Cave': (0x127, 0x400), + 'Pyramid Fairy - Left': (0x116, 0x10), + 'Pyramid Fairy - Right': (0x116, 0x20), + 'Brewery': (0x106, 0x10), + 'C-Shaped House': (0x11c, 0x10), + 'Chest Game': (0x106, 0x400), + 'Mire Shed - Left': (0x10d, 0x10), + 'Mire Shed - Right': (0x10d, 0x20), + 'Superbunny Cave - Top': (0xf8, 0x10), + 'Superbunny Cave - Bottom': (0xf8, 0x20), + 'Spike Cave': (0x117, 0x10), + 'Hookshot Cave - Top Right': (0x3c, 0x10), + 'Hookshot Cave - Top Left': (0x3c, 0x20), + 'Hookshot Cave - Bottom Right': (0x3c, 0x80), + 'Hookshot Cave - Bottom Left': (0x3c, 0x40), + 'Mimic Cave': (0x10c, 0x10), + 'Swamp Palace - Entrance': (0x28, 0x10), + 'Swamp Palace - Map Chest': (0x37, 0x10), + 'Swamp Palace - Pot Row Pot Key': (0x38, 0x400), + 'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400), + 'Swamp Palace - Hookshot Pot Key': (0x36, 0x400), + 'Swamp Palace - Big Chest': (0x36, 0x10), + 'Swamp Palace - Compass Chest': (0x46, 0x10), + 'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400), + 'Swamp Palace - Big Key Chest': (0x35, 0x10), + 'Swamp Palace - West Chest': (0x34, 0x10), + 'Swamp Palace - Flooded Room - Left': (0x76, 0x10), + 'Swamp Palace - Flooded Room - Right': (0x76, 0x20), + 'Swamp Palace - Waterfall Room': (0x66, 0x10), + 'Swamp Palace - Waterway Pot Key': (0x16, 0x400), + 'Swamp Palace - Boss': (0x6, 0x800), + "Thieves' Town - Big Key Chest": (0xdb, 0x20), + "Thieves' Town - Map Chest": (0xdb, 0x10), + "Thieves' Town - Compass Chest": (0xdc, 0x10), + "Thieves' Town - Ambush Chest": (0xcb, 0x10), + "Thieves' Town - Hallway Pot Key": (0xbc, 0x400), + "Thieves' Town - Spike Switch Pot Key": (0xab, 0x400), + "Thieves' Town - Attic": (0x65, 0x10), + "Thieves' Town - Big Chest": (0x44, 0x10), + "Thieves' Town - Blind's Cell": (0x45, 0x10), + "Thieves' Town - Boss": (0xac, 0x800), + 'Skull Woods - Compass Chest': (0x67, 0x10), + 'Skull Woods - Map Chest': (0x58, 0x20), + 'Skull Woods - Big Chest': (0x58, 0x10), + 'Skull Woods - Pot Prison': (0x57, 0x20), + 'Skull Woods - Pinball Room': (0x68, 0x10), + 'Skull Woods - Big Key Chest': (0x57, 0x10), + 'Skull Woods - West Lobby Pot Key': (0x56, 0x400), + 'Skull Woods - Bridge Room': (0x59, 0x10), + 'Skull Woods - Spike Corner Key Drop': (0x39, 0x400), + 'Skull Woods - Boss': (0x29, 0x800), + 'Ice Palace - Jelly Key Drop': (0x0e, 0x400), + 'Ice Palace - Compass Chest': (0x2e, 0x10), + 'Ice Palace - Conveyor Key Drop': (0x3e, 0x400), + 'Ice Palace - Freezor Chest': (0x7e, 0x10), + 'Ice Palace - Big Chest': (0x9e, 0x10), + 'Ice Palace - Iced T Room': (0xae, 0x10), + 'Ice Palace - Many Pots Pot Key': (0x9f, 0x400), + 'Ice Palace - Spike Room': (0x5f, 0x10), + 'Ice Palace - Big Key Chest': (0x1f, 0x10), + 'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400), + 'Ice Palace - Map Chest': (0x3f, 0x10), + 'Ice Palace - Boss': (0xde, 0x800), + 'Misery Mire - Big Chest': (0xc3, 0x10), + 'Misery Mire - Map Chest': (0xc3, 0x20), + 'Misery Mire - Main Lobby': (0xc2, 0x10), + 'Misery Mire - Bridge Chest': (0xa2, 0x10), + 'Misery Mire - Spikes Pot Key': (0xb3, 0x400), + 'Misery Mire - Spike Chest': (0xb3, 0x10), + 'Misery Mire - Fishbone Pot Key': (0xa1, 0x400), + 'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400), + 'Misery Mire - Compass Chest': (0xc1, 0x10), + 'Misery Mire - Big Key Chest': (0xd1, 0x10), + 'Misery Mire - Boss': (0x90, 0x800), + 'Turtle Rock - Compass Chest': (0xd6, 0x10), + 'Turtle Rock - Roller Room - Left': (0xb7, 0x10), + 'Turtle Rock - Roller Room - Right': (0xb7, 0x20), + 'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400), + 'Turtle Rock - Chain Chomps': (0xb6, 0x10), + 'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400), + 'Turtle Rock - Big Key Chest': (0x14, 0x10), + 'Turtle Rock - Big Chest': (0x24, 0x10), + 'Turtle Rock - Crystaroller Room': (0x4, 0x10), + 'Turtle Rock - Eye Bridge - Bottom Left': (0xd5, 0x80), + 'Turtle Rock - Eye Bridge - Bottom Right': (0xd5, 0x40), + 'Turtle Rock - Eye Bridge - Top Left': (0xd5, 0x20), + 'Turtle Rock - Eye Bridge - Top Right': (0xd5, 0x10), + 'Turtle Rock - Boss': (0xa4, 0x800), + 'Palace of Darkness - Shooter Room': (0x9, 0x10), + 'Palace of Darkness - The Arena - Bridge': (0x2a, 0x20), + 'Palace of Darkness - Stalfos Basement': (0xa, 0x10), + 'Palace of Darkness - Big Key Chest': (0x3a, 0x10), + 'Palace of Darkness - The Arena - Ledge': (0x2a, 0x10), + 'Palace of Darkness - Map Chest': (0x2b, 0x10), + 'Palace of Darkness - Compass Chest': (0x1a, 0x20), + 'Palace of Darkness - Dark Basement - Left': (0x6a, 0x10), + 'Palace of Darkness - Dark Basement - Right': (0x6a, 0x20), + 'Palace of Darkness - Dark Maze - Top': (0x19, 0x10), + 'Palace of Darkness - Dark Maze - Bottom': (0x19, 0x20), + 'Palace of Darkness - Big Chest': (0x1a, 0x10), + 'Palace of Darkness - Harmless Hellway': (0x1a, 0x40), + 'Palace of Darkness - Boss': (0x5a, 0x800), + 'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400), + "Ganons Tower - Bob's Torch": (0x8c, 0x400), + 'Ganons Tower - Hope Room - Left': (0x8c, 0x20), + 'Ganons Tower - Hope Room - Right': (0x8c, 0x40), + 'Ganons Tower - Tile Room': (0x8d, 0x10), + 'Ganons Tower - Compass Room - Top Left': (0x9d, 0x10), + 'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20), + 'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40), + 'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80), + 'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400), + 'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10), + 'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20), + 'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40), + 'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80), + 'Ganons Tower - Map Chest': (0x8b, 0x10), + 'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400), + 'Ganons Tower - Firesnake Room': (0x7d, 0x10), + 'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10), + 'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20), + 'Ganons Tower - Randomizer Room - Bottom Left': (0x7c, 0x40), + 'Ganons Tower - Randomizer Room - Bottom Right': (0x7c, 0x80), + "Ganons Tower - Bob's Chest": (0x8c, 0x80), + 'Ganons Tower - Big Chest': (0x8c, 0x10), + 'Ganons Tower - Big Key Room - Left': (0x1c, 0x20), + 'Ganons Tower - Big Key Room - Right': (0x1c, 0x40), + 'Ganons Tower - Big Key Chest': (0x1c, 0x10), + 'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10), + 'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20), + 'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400), + 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), + 'Ganons Tower - Validation Chest': (0x4d, 0x10)} + +boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss', + 'Desert Palace - Boss', + 'Tower of Hera - Boss', + 'Palace of Darkness - Boss', + 'Swamp Palace - Boss', + 'Skull Woods - Boss', + "Thieves' Town - Boss", + 'Ice Palace - Boss', + 'Misery Mire - Boss', + 'Turtle Rock - Boss', + 'Sahasrahla'}} + +location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()} + +location_table_npc = {'Mushroom': 0x1000, + 'King Zora': 0x2, + 'Sahasrahla': 0x10, + 'Blacksmith': 0x400, + 'Magic Bat': 0x8000, + 'Sick Kid': 0x4, + 'Library': 0x80, + 'Potion Shop': 0x2000, + 'Old Man': 0x1, + 'Ether Tablet': 0x100, + 'Catfish': 0x20, + 'Stumpy': 0x8, + 'Bombos Tablet': 0x200} + +location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()} + +location_table_ow = {'Flute Spot': 0x2a, + 'Sunken Treasure': 0x3b, + "Zora's Ledge": 0x81, + 'Lake Hylia Island': 0x35, + 'Maze Race': 0x28, + 'Desert Ledge': 0x30, + 'Master Sword Pedestal': 0x80, + 'Spectacle Rock': 0x3, + 'Pyramid': 0x5b, + 'Digging Game': 0x68, + 'Bumper Cave Ledge': 0x4a, + 'Floating Island': 0x5} + +location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()} + +location_table_misc = {'Bottle Merchant': (0x3c9, 0x2), + 'Purple Chest': (0x3c9, 0x10), + "Link's Uncle": (0x3c6, 0x1), + 'Hobo': (0x3c9, 0x1)} +location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()} + + +async def track_locations(ctx, roomid, roomdata): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + new_locations = [] + + def new_check(location_id): + new_locations.append(location_id) + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + + try: + shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN) + shop_data_changed = False + shop_data = list(shop_data) + for cnt, b in enumerate(shop_data): + location = Shops.SHOP_ID_START + cnt + if int(b) and location not in ctx.locations_checked: + new_check(location) + if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \ + and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot: + if not int(b): + shop_data[cnt] += 1 + shop_data_changed = True + if shop_data_changed: + snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data)) + except Exception as e: + snes_logger.info(f"Exception: {e}") + + for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items(): + try: + if location_id not in ctx.locations_checked and loc_roomid == roomid and \ + (roomdata << 4) & loc_mask != 0: + new_check(location_id) + except Exception as e: + snes_logger.exception(f"Exception: {e}") + + uw_begin = 0x129 + ow_end = uw_end = 0 + uw_unchecked = {} + uw_checked = {} + for location, (roomid, mask) in location_table_uw.items(): + location_id = Regions.lookup_name_to_id[location] + if location_id not in ctx.locations_checked: + uw_unchecked[location_id] = (roomid, mask) + uw_begin = min(uw_begin, roomid) + uw_end = max(uw_end, roomid + 1) + if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ + and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + uw_begin = min(uw_begin, roomid) + uw_end = max(uw_end, roomid + 1) + uw_checked[location_id] = (roomid, mask) + + if uw_begin < uw_end: + uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2) + if uw_data is not None: + for location_id, (roomid, mask) in uw_unchecked.items(): + offset = (roomid - uw_begin) * 2 + roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) + if roomdata & mask != 0: + new_check(location_id) + if uw_checked: + uw_data = list(uw_data) + for location_id, (roomid, mask) in uw_checked.items(): + offset = (roomid - uw_begin) * 2 + roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) + roomdata |= mask + uw_data[offset] = roomdata & 0xFF + uw_data[offset + 1] = roomdata >> 8 + snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data)) + + ow_begin = 0x82 + ow_unchecked = {} + ow_checked = {} + for location_id, screenid in location_table_ow_id.items(): + if location_id not in ctx.locations_checked: + ow_unchecked[location_id] = screenid + ow_begin = min(ow_begin, screenid) + ow_end = max(ow_end, screenid + 1) + if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + ow_checked[location_id] = screenid + + if ow_begin < ow_end: + ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin) + if ow_data is not None: + for location_id, screenid in ow_unchecked.items(): + if ow_data[screenid - ow_begin] & 0x40 != 0: + new_check(location_id) + if ow_checked: + ow_data = list(ow_data) + for location_id, screenid in ow_checked.items(): + ow_data[screenid - ow_begin] |= 0x40 + snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data)) + + if not ctx.locations_checked.issuperset(location_table_npc_id): + npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2) + if npc_data is not None: + npc_value_changed = False + npc_value = npc_data[0] | (npc_data[1] << 8) + for location_id, mask in location_table_npc_id.items(): + if npc_value & mask != 0 and location_id not in ctx.locations_checked: + new_check(location_id) + if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ + and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + npc_value |= mask + npc_value_changed = True + if npc_value_changed: + npc_data = bytes([npc_value & 0xFF, npc_value >> 8]) + snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data) + + if not ctx.locations_checked.issuperset(location_table_misc_id): + misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4) + if misc_data is not None: + misc_data = list(misc_data) + misc_data_changed = False + for location_id, (offset, mask) in location_table_misc_id.items(): + assert (0x3c6 <= offset <= 0x3c9) + if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: + new_check(location_id) + if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \ + and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot: + misc_data_changed = True + misc_data[offset - 0x3c6] |= mask + if misc_data_changed: + snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data)) + + + if new_locations: + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) + await snes_flush_writes(ctx) + + +def get_alttp_settings(romfile: str): + lastSettings = Utils.get_adjuster_settings(GAME_ALTTP) + adjustedromfile = '' + if lastSettings: + choice = 'no' + if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply: + + whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap", + "uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes", + "reduceflashing", "deathlink", "allowcollect"} + printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist} + if hasattr(lastSettings, "sprite_pool"): + sprite_pool = {} + for sprite in lastSettings.sprite_pool: + if sprite in sprite_pool: + sprite_pool[sprite] += 1 + else: + sprite_pool[sprite] = 1 + if sprite_pool: + printed_options["sprite_pool"] = sprite_pool + import pprint + + from CommonClient import gui_enabled + if gui_enabled: + + try: + from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button + applyPromptWindow = Tk() + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed.') + return '', False + + applyPromptWindow.resizable(False, False) + applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick()) + logo = PhotoImage(file=Utils.local_path('data', 'icon.png')) + applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo) + applyPromptWindow.wm_title("Last adjuster settings LttP") + + label = LabelFrame(applyPromptWindow, + text='Last used adjuster settings were found. Would you like to apply these?') + label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5) + label.grid_columnconfigure(0, weight=1) + label.grid_columnconfigure(1, weight=1) + label.grid_columnconfigure(2, weight=1) + label.grid_columnconfigure(3, weight=1) + + def onButtonClick(answer: str = 'no'): + setattr(onButtonClick, 'choice', answer) + applyPromptWindow.destroy() + + framedOptions = Frame(label) + framedOptions.grid(column=0, columnspan=4, row=0) + framedOptions.grid_columnconfigure(0, weight=1) + framedOptions.grid_columnconfigure(1, weight=1) + framedOptions.grid_columnconfigure(2, weight=1) + curRow = 0 + curCol = 0 + for name, value in printed_options.items(): + Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5) + if (curCol == 2): + curRow += 1 + curCol = 0 + else: + curCol += 1 + + yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10) + yesButton.grid(column=0, row=1) + noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10) + noButton.grid(column=1, row=1) + alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10) + alwaysButton.grid(column=2, row=1) + neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10) + neverButton.grid(column=3, row=1) + + Utils.tkinter_center_window(applyPromptWindow) + applyPromptWindow.mainloop() + choice = getattr(onButtonClick, 'choice') + else: + choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n" + f"{pprint.pformat(printed_options)}\n" + f"Enter yes, no, always or never: ") + if choice and choice.startswith("y"): + choice = 'yes' + elif choice and "never" in choice: + choice = 'no' + lastSettings.auto_apply = 'never' + Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) + elif choice and "always" in choice: + choice = 'yes' + lastSettings.auto_apply = 'always' + Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) + else: + choice = 'no' + elif 'never' in lastSettings.auto_apply: + choice = 'no' + elif 'always' in lastSettings.auto_apply: + choice = 'yes' + + if 'yes' in choice: + from worlds.alttp.Rom import get_base_rom_path + lastSettings.rom = romfile + lastSettings.baserom = get_base_rom_path() + lastSettings.world = None + + if hasattr(lastSettings, "sprite_pool"): + from LttPAdjuster import AdjusterWorld + lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool")) + + adjusted = True + import LttPAdjuster + _, adjustedromfile = LttPAdjuster.adjust(lastSettings) + + if hasattr(lastSettings, "world"): + delattr(lastSettings, "world") + else: + adjusted = False + if adjusted: + try: + shutil.move(adjustedromfile, romfile) + adjustedromfile = romfile + except Exception as e: + logging.exception(e) + else: + adjusted = False + return adjustedromfile, adjusted + + +class ALTTPSNIClient(SNIClient): + game = "A Link to the Past" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_read, snes_buffered_write, snes_flush_writes + invincible = await snes_read(ctx, WRAM_START + 0x037B, 1) + last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1) + await asyncio.sleep(0.25) + health = await snes_read(ctx, WRAM_START + 0xF36D, 1) + if not invincible or not last_health or not health: + ctx.death_state = DeathState.dead + ctx.last_death_link = time.time() + return + if not invincible[0] and last_health[0] == health[0]: + snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0 + snes_buffered_write(ctx, WRAM_START + 0x0373, + bytes([8])) # deal 1 full heart of damage at next opportunity + + await snes_flush_writes(ctx) + await asyncio.sleep(1) + + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + if not gamemode or gamemode[0] in DEATH_MODES: + ctx.death_state = DeathState.dead + + + async def validate_rom(self, ctx): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + + rom_name = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:2] != b"AP": + return False + + ctx.game = self.game + ctx.items_handling = 0b001 # full local + + ctx.rom = rom_name + + death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1) + + if death_link: + ctx.allow_collect = bool(death_link[0] & 0b100) + ctx.death_link_allow_survive = bool(death_link[0] & 0b10) + await ctx.update_death_link(bool(death_link[0] & 0b1)) + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): + currently_dead = gamemode[0] in DEATH_MODES + await ctx.handle_deathlink_state(currently_dead) + + gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) + game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) + if gamemode is None or gameend is None or game_timer is None or \ + (gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES): + return + + if gameend[0]: + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + + if gamemode in ENDGAME_MODES: # triforce room and credits + return + + data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] + roomid = data[4] | (data[5] << 8) + roomdata = data[6] + scout_location = data[7] + + if recv_index < len(ctx.items_received) and recv_item == 0: + item = ctx.items_received[recv_index] + recv_index += 1 + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], recv_index, len(ctx.items_received))) + + snes_buffered_write(ctx, RECV_PROGRESS_ADDR, + bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + snes_buffered_write(ctx, RECV_ITEM_ADDR, + bytes([item.item])) + snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, + bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0])) + if scout_location > 0 and scout_location in ctx.locations_info: + snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, + bytes([scout_location])) + snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, + bytes([ctx.locations_info[scout_location].item])) + snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, + bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)])) + + await snes_flush_writes(ctx) + + if scout_location > 0 and scout_location not in ctx.locations_scouted: + ctx.locations_scouted.add(scout_location) + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) + await track_locations(ctx, roomid, roomdata) diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index ce53154e92..8431af9a26 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -15,6 +15,7 @@ from .Items import item_init_table, item_name_groups, item_table, GetBeemizerIte from .Options import alttp_options, smallkey_shuffle from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions, lookup_vanilla_location_to_entrance, \ is_main_entrance +from .Client import ALTTPSNIClient from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \ get_hash_string, get_base_rom_path, LttPDeltaPatch from .Rules import set_rules diff --git a/worlds/dkc3/Client.py b/worlds/dkc3/Client.py index 7ab82187b0..77ed51fecb 100644 --- a/worlds/dkc3/Client.py +++ b/worlds/dkc3/Client.py @@ -2,75 +2,69 @@ import logging import asyncio from NetUtils import ClientStatus, color -from SNIClient import Context, snes_buffered_write, snes_flush_writes, snes_read -from Patch import GAME_DKC3 +from worlds.AutoSNIClient import SNIClient snes_logger = logging.getLogger("SNES") -# DKC3 - DKC3_TODO: Check these values +# FXPAK Pro protocol memory mapping used by SNI ROM_START = 0x000000 WRAM_START = 0xF50000 WRAM_SIZE = 0x20000 SRAM_START = 0xE00000 -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - DKC3_ROMNAME_START = 0x00FFC0 DKC3_ROMHASH_START = 0x7FC0 ROMNAME_SIZE = 0x15 ROMHASH_SIZE = 0x15 -DKC3_RECV_PROGRESS_ADDR = WRAM_START + 0x632 # DKC3_TODO: Find a permanent home for this +DKC3_RECV_PROGRESS_ADDR = WRAM_START + 0x632 DKC3_FILE_NAME_ADDR = WRAM_START + 0x5D9 DEATH_LINK_ACTIVE_ADDR = DKC3_ROMNAME_START + 0x15 # DKC3_TODO: Find a permanent home for this -async def deathlink_kill_player(ctx: Context): - pass - #if ctx.game == GAME_DKC3: +class DKC3SNIClient(SNIClient): + game = "Donkey Kong Country 3" + + async def deathlink_kill_player(self, ctx): + pass # DKC3_TODO: Handle Receiving Deathlink -async def dkc3_rom_init(ctx: Context): - if not ctx.rom: - ctx.finished_game = False - ctx.death_link_allow_survive = False - game_name = await snes_read(ctx, DKC3_ROMNAME_START, 0x15) - if game_name is None or game_name != b"DONKEY KONG COUNTRY 3": - return False - else: - ctx.game = GAME_DKC3 - ctx.items_handling = 0b111 # remote items + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read - rom = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) - if rom is None or rom == bytes([0] * ROMHASH_SIZE): + rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3": return False - ctx.rom = rom + ctx.game = self.game + ctx.items_handling = 0b111 # remote items + + ctx.rom = rom_name #death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1) ## DKC3_TODO: Handle Deathlink #if death_link: # ctx.allow_collect = bool(death_link[0] & 0b100) # await ctx.update_death_link(bool(death_link[0] & 0b1)) - return True + return True -async def dkc3_game_watcher(ctx: Context): - if ctx.game == GAME_DKC3: + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read # DKC3_TODO: Handle Deathlink save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5) - if save_file_name is None or save_file_name[0] == 0x00: + if save_file_name is None or save_file_name[0] == 0x00 or save_file_name == bytes([0x55] * 0x05): # We haven't loaded a save file return new_checks = [] from worlds.dkc3.Rom import location_rom_data, item_rom_data, boss_location_ids, level_unlock_map + location_ram_data = await snes_read(ctx, WRAM_START + 0x5FE, 0x81) for loc_id, loc_data in location_rom_data.items(): if loc_id not in ctx.locations_checked: - data = await snes_read(ctx, WRAM_START + loc_data[0], 1) - masked_data = data[0] & (1 << loc_data[1]) + data = location_ram_data[loc_data[0] - 0x5FE] + masked_data = data & (1 << loc_data[1]) bit_set = (masked_data != 0) invert_bit = ((len(loc_data) >= 3) and loc_data[2]) if bit_set != invert_bit: @@ -78,8 +72,9 @@ async def dkc3_game_watcher(ctx: Context): new_checks.append(loc_id) verify_save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5) - if verify_save_file_name is None or verify_save_file_name[0] == 0x00 or verify_save_file_name != save_file_name: + if verify_save_file_name is None or verify_save_file_name[0] == 0x00 or verify_save_file_name == bytes([0x55] * 0x05) or verify_save_file_name != save_file_name: # We have somehow exited the save file (or worse) + ctx.rom = None return rom = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) @@ -184,8 +179,9 @@ async def dkc3_game_watcher(ctx: Context): await snes_flush_writes(ctx) - # DKC3_TODO: This method of collect should work, however it does not unlock the next level correctly when previous is flagged # Handle Collected Locations + levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60) + tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60) for loc_id in ctx.checked_locations: if loc_id not in ctx.locations_checked and loc_id not in boss_location_ids: loc_data = location_rom_data[loc_id] @@ -193,30 +189,24 @@ async def dkc3_game_watcher(ctx: Context): invert_bit = ((len(loc_data) >= 3) and loc_data[2]) if not invert_bit: masked_data = data[0] | (1 << loc_data[1]) - #print("Collected Location: ", hex(loc_data[0]), " | ", loc_data[1]) snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data])) if (loc_data[1] == 1): # Make the next levels accessible level_id = loc_data[0] - 0x632 - levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60) - tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60) tile_id = levels_to_tiles[level_id] if levels_to_tiles[level_id] != 0xFF else level_id tile_id = tile_id + 0x632 - #print("Tile ID: ", hex(tile_id)) if tile_id in level_unlock_map: for next_level_address in level_unlock_map[tile_id]: next_level_id = next_level_address - 0x632 next_tile_id = tiles_to_levels[next_level_id] if tiles_to_levels[next_level_id] != 0xFF else next_level_id next_tile_id = next_tile_id + 0x632 - #print("Next Level ID: ", hex(next_tile_id)) next_data = await snes_read(ctx, WRAM_START + next_tile_id, 1) snes_buffered_write(ctx, WRAM_START + next_tile_id, bytes([next_data[0] | 0x01])) await snes_flush_writes(ctx) else: masked_data = data[0] & ~(1 << loc_data[1]) - print("Collected Inverted Location: ", hex(loc_data[0]), " | ", loc_data[1]) snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data])) await snes_flush_writes(ctx) ctx.locations_checked.add(loc_id) diff --git a/worlds/dkc3/__init__.py b/worlds/dkc3/__init__.py index d45de8f85a..332f23e491 100644 --- a/worlds/dkc3/__init__.py +++ b/worlds/dkc3/__init__.py @@ -11,6 +11,7 @@ from .Regions import create_regions, connect_regions from .Levels import level_list from .Rules import set_rules from .Names import ItemName, LocationName +from .Client import DKC3SNIClient from ..AutoWorld import WebWorld, World from .Rom import LocalRom, patch_rom, get_base_rom_path, DKC3DeltaPatch import Patch diff --git a/worlds/sm/Client.py b/worlds/sm/Client.py new file mode 100644 index 0000000000..190ce29ecc --- /dev/null +++ b/worlds/sm/Client.py @@ -0,0 +1,158 @@ +import logging +import asyncio +import time + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient +from .Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +GAME_SM = "Super Metroid" + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +# SM +SM_ROMNAME_START = ROM_START + 0x007FC0 +ROMNAME_SIZE = 0x15 + +SM_INGAME_MODES = {0x07, 0x09, 0x0b} +SM_ENDGAME_MODES = {0x26, 0x27} +SM_DEATH_MODES = {0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A} + +# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue +SM_RECV_QUEUE_START = SRAM_START + 0x2000 +SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 +SM_SEND_QUEUE_START = SRAM_START + 0x2700 +SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680 +SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682 + +SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277F04 # 1 byte +SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277F06 # 1 byte + + +class SMSNIClient(SNIClient): + game = "Super Metroid" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read + snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy) + snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity + if not ctx.death_link_allow_survive: + snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0 + + await snes_flush_writes(ctx) + await asyncio.sleep(1) + + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + health = await snes_read(ctx, WRAM_START + 0x09C2, 2) + if health is not None: + health = health[0] | (health[1] << 8) + if not gamemode or gamemode[0] in SM_DEATH_MODES or ( + ctx.death_link_allow_survive and health is not None and health > 0): + ctx.death_state = DeathState.dead + + + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + rom_name = await snes_read(ctx, SM_ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:2] != b"SM" or rom_name[:3] == b"SMW": + return False + + ctx.game = self.game + + # versions lower than 0.3.0 dont have item handling flag nor remote item support + romVersion = int(rom_name[2:5].decode('UTF-8')) + if romVersion < 30: + ctx.items_handling = 0b001 # full local + else: + item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1) + ctx.items_handling = 0b001 if item_handling is None else item_handling[0] + + ctx.rom = rom_name + + death_link = await snes_read(ctx, SM_DEATH_LINK_ACTIVE_ADDR, 1) + + if death_link: + ctx.allow_collect = bool(death_link[0] & 0b100) + ctx.death_link_allow_survive = bool(death_link[0] & 0b10) + await ctx.update_death_link(bool(death_link[0] & 0b1)) + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + return + + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): + currently_dead = gamemode[0] in SM_DEATH_MODES + await ctx.handle_deathlink_state(currently_dead) + if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES: + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + return + + data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT + + while (recv_index < recv_item): + item_address = recv_index * 8 + message = await snes_read(ctx, SM_SEND_QUEUE_START + item_address, 8) + item_index = (message[4] | (message[5] << 8)) >> 3 + + recv_index += 1 + snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, + bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + + from worlds.sm import locations_start_id + location_id = locations_start_id + item_index + + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + + data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) + if data is None: + return + + item_out_ptr = data[0] | (data[1] << 8) + + from worlds.sm import items_start_id + from worlds.sm import locations_start_id + if item_out_ptr < len(ctx.items_received): + item = ctx.items_received[item_out_ptr] + item_id = item.item - items_start_id + if bool(ctx.items_handling & 0b010): + location_id = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF + else: + location_id = 0x00 #backward compat + + player_id = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 + snes_buffered_write(ctx, SM_RECV_QUEUE_START + item_out_ptr * 4, bytes( + [player_id & 0xFF, (player_id >> 8) & 0xFF, item_id & 0xFF, location_id & 0xFF])) + item_out_ptr += 1 + snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, + bytes([item_out_ptr & 0xFF, (item_out_ptr >> 8) & 0xFF])) + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], item_out_ptr, len(ctx.items_received))) + + await snes_flush_writes(ctx) + diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index 500233bb71..fc19b4e133 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -14,6 +14,7 @@ logger = logging.getLogger("Super Metroid") from .Regions import create_regions from .Rules import set_rules, add_entrance_rule from .Options import sm_options +from .Client import SMSNIClient from .Rom import get_base_rom_path, ROM_PLAYER_LIMIT, SMDeltaPatch, get_sm_symbols import Utils diff --git a/worlds/smw/Client.py b/worlds/smw/Client.py index 6ddd4e1073..9cf5a5fcfb 100644 --- a/worlds/smw/Client.py +++ b/worlds/smw/Client.py @@ -3,21 +3,17 @@ import asyncio import time from NetUtils import ClientStatus, color -from worlds import AutoWorldRegister -from SNIClient import Context, snes_buffered_write, snes_flush_writes, snes_read +from worlds.AutoSNIClient import SNIClient from .Names.TextBox import generate_received_text -from Patch import GAME_SMW snes_logger = logging.getLogger("SNES") +# FXPAK Pro protocol memory mapping used by SNI ROM_START = 0x000000 WRAM_START = 0xF50000 WRAM_SIZE = 0x20000 SRAM_START = 0xE00000 -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - SMW_ROMHASH_START = 0x7FC0 ROMHASH_SIZE = 0x15 @@ -58,8 +54,12 @@ SMW_BAD_TEXT_BOX_LEVELS = [0x26, 0x02, 0x4B] SMW_BOSS_STATES = [0x80, 0xC0, 0xC1] SMW_UNCOLLECTABLE_LEVELS = [0x25, 0x07, 0x0B, 0x40, 0x0E, 0x1F, 0x20, 0x1B, 0x1A, 0x35, 0x34, 0x31, 0x32] -async def deathlink_kill_player(ctx: Context): - if ctx.game == GAME_SMW: + +class SMWSNIClient(SNIClient): + game = "Super Mario World" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) if game_state[0] != 0x14: return @@ -88,25 +88,19 @@ async def deathlink_kill_player(ctx: Context): await snes_flush_writes(ctx) - from SNIClient import DeathState ctx.death_state = DeathState.dead ctx.last_death_link = time.time() - return + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read -async def smw_rom_init(ctx: Context): - if not ctx.rom: - ctx.finished_game = False - ctx.death_link_allow_survive = False - game_hash = await snes_read(ctx, SMW_ROMHASH_START, ROMHASH_SIZE) - if game_hash is None or game_hash == bytes([0] * ROMHASH_SIZE) or game_hash[:3] != b"SMW": + rom_name = await snes_read(ctx, SMW_ROMHASH_START, ROMHASH_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:3] != b"SMW": return False - else: - ctx.game = GAME_SMW - ctx.items_handling = 0b111 # remote items - ctx.rom = game_hash + ctx.game = self.game + ctx.items_handling = 0b111 # remote items receive_option = await snes_read(ctx, SMW_RECEIVE_MSG_DATA, 0x1) send_option = await snes_read(ctx, SMW_SEND_MSG_DATA, 0x1) @@ -114,73 +108,73 @@ async def smw_rom_init(ctx: Context): ctx.receive_option = receive_option[0] ctx.send_option = send_option[0] - ctx.message_queue = [] - ctx.allow_collect = True death_link = await snes_read(ctx, SMW_DEATH_LINK_ACTIVE_ADDR, 1) if death_link: await ctx.update_death_link(bool(death_link[0] & 0b1)) - return True + + ctx.rom = rom_name + + return True -def add_message_to_queue(ctx: Context, new_message): + def add_message_to_queue(self, new_message): - if not hasattr(ctx, "message_queue"): - ctx.message_queue = [] + if not hasattr(self, "message_queue"): + self.message_queue = [] - ctx.message_queue.append(new_message) - - return + self.message_queue.append(new_message) -async def handle_message_queue(ctx: Context): + async def handle_message_queue(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + if not hasattr(self, "message_queue") or len(self.message_queue) == 0: + return + + game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) + if game_state[0] != 0x14: + return + + mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) + if mario_state[0] != 0x00: + return + + message_box = await snes_read(ctx, SMW_MESSAGE_BOX_ADDR, 0x1) + if message_box[0] != 0x00: + return + + pause_state = await snes_read(ctx, SMW_PAUSE_ADDR, 0x1) + if pause_state[0] != 0x00: + return + + current_level = await snes_read(ctx, SMW_CURRENT_LEVEL_ADDR, 0x1) + if current_level[0] in SMW_BAD_TEXT_BOX_LEVELS: + return + + boss_state = await snes_read(ctx, SMW_BOSS_STATE_ADDR, 0x1) + if boss_state[0] in SMW_BOSS_STATES: + return + + active_boss = await snes_read(ctx, SMW_ACTIVE_BOSS_ADDR, 0x1) + if active_boss[0] != 0x00: + return + + next_message = self.message_queue.pop(0) + + snes_buffered_write(ctx, SMW_MESSAGE_QUEUE_ADDR, bytes(next_message)) + snes_buffered_write(ctx, SMW_MESSAGE_BOX_ADDR, bytes([0x03])) + snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x22])) + + await snes_flush_writes(ctx) - game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) - if game_state[0] != 0x14: return - mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) - if mario_state[0] != 0x00: - return - message_box = await snes_read(ctx, SMW_MESSAGE_BOX_ADDR, 0x1) - if message_box[0] != 0x00: - return + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read - pause_state = await snes_read(ctx, SMW_PAUSE_ADDR, 0x1) - if pause_state[0] != 0x00: - return - - current_level = await snes_read(ctx, SMW_CURRENT_LEVEL_ADDR, 0x1) - if current_level[0] in SMW_BAD_TEXT_BOX_LEVELS: - return - - boss_state = await snes_read(ctx, SMW_BOSS_STATE_ADDR, 0x1) - if boss_state[0] in SMW_BOSS_STATES: - return - - active_boss = await snes_read(ctx, SMW_ACTIVE_BOSS_ADDR, 0x1) - if active_boss[0] != 0x00: - return - - if not hasattr(ctx, "message_queue") or len(ctx.message_queue) == 0: - return - - next_message = ctx.message_queue.pop(0) - - snes_buffered_write(ctx, SMW_MESSAGE_QUEUE_ADDR, bytes(next_message)) - snes_buffered_write(ctx, SMW_MESSAGE_BOX_ADDR, bytes([0x03])) - snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x22])) - - await snes_flush_writes(ctx) - - return - - -async def smw_game_watcher(ctx: Context): - if ctx.game == GAME_SMW: - # SMW_TODO: Handle Deathlink game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) if game_state is None: @@ -234,7 +228,7 @@ async def smw_game_watcher(ctx: Context): snes_buffered_write(ctx, SMW_BONUS_STAR_ADDR, bytes([egg_count[0]])) await snes_flush_writes(ctx) - await handle_message_queue(ctx) + await self.handle_message_queue(ctx) new_checks = [] event_data = await snes_read(ctx, SMW_EVENT_ROM_DATA, 0x60) @@ -243,6 +237,7 @@ async def smw_game_watcher(ctx: Context): dragon_coins_active = await snes_read(ctx, SMW_DRAGON_COINS_ACTIVE_ADDR, 0x1) from worlds.smw.Rom import item_rom_data, ability_rom_data from worlds.smw.Levels import location_id_to_level_id, level_info_dict + from worlds import AutoWorldRegister for loc_name, level_data in location_id_to_level_id.items(): loc_id = AutoWorldRegister.world_types[ctx.game].location_name_to_id[loc_name] if loc_id not in ctx.locations_checked: @@ -262,7 +257,6 @@ async def smw_game_watcher(ctx: Context): bit_set = (masked_data != 0) if bit_set: - # SMW_TODO: Handle non-included checks new_checks.append(loc_id) else: event_id_value = event_id + level_data[1] @@ -275,7 +269,6 @@ async def smw_game_watcher(ctx: Context): bit_set = (masked_data != 0) if bit_set: - # SMW_TODO: Handle non-included checks new_checks.append(loc_id) verify_game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) @@ -320,7 +313,7 @@ async def smw_game_watcher(ctx: Context): player_name = ctx.player_names[item.player] receive_message = generate_received_text(item_name, player_name) - add_message_to_queue(ctx, receive_message) + self.add_message_to_queue(receive_message) snes_buffered_write(ctx, SMW_RECV_PROGRESS_ADDR, bytes([recv_index])) if item.item in item_rom_data: @@ -372,7 +365,7 @@ async def smw_game_watcher(ctx: Context): rand_trap = random.choice(lit_trap_text_list) for message in rand_trap: - add_message_to_queue(ctx, message) + self.add_message_to_queue(message) await snes_flush_writes(ctx) diff --git a/worlds/smw/__init__.py b/worlds/smw/__init__.py index 1dd64f535f..2e9be535e9 100644 --- a/worlds/smw/__init__.py +++ b/worlds/smw/__init__.py @@ -12,6 +12,7 @@ from .Levels import full_level_list, generate_level_list, location_id_to_level_i from .Rules import set_rules from ..generic.Rules import add_rule from .Names import ItemName, LocationName +from .Client import SMWSNIClient from ..AutoWorld import WebWorld, World from .Rom import LocalRom, patch_rom, get_base_rom_path, SMWDeltaPatch diff --git a/worlds/smz3/Client.py b/worlds/smz3/Client.py new file mode 100644 index 0000000000..c942c66c71 --- /dev/null +++ b/worlds/smz3/Client.py @@ -0,0 +1,118 @@ +import logging +import asyncio +import time + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient +from .Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +# SMZ3 +SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 +ROMNAME_SIZE = 0x15 + +SAVEDATA_START = WRAM_START + 0xF000 + +SMZ3_INGAME_MODES = {0x07, 0x09, 0x0B} +ENDGAME_MODES = {0x19, 0x1A} +SM_ENDGAME_MODES = {0x26, 0x27} +SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} + +SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes +SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte +SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte + + +class SMZ3SNIClient(SNIClient): + game = "SMZ3" + + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + rom_name = await snes_read(ctx, SMZ3_ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:3] != b"ZSM": + return False + + ctx.game = self.game + ctx.items_handling = 0b101 # local items and remote start inventory + + ctx.rom = rom_name + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + return + + currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) + if (currentGame is not None): + if (currentGame[0] != 0): + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + endGameModes = SM_ENDGAME_MODES + else: + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + endGameModes = ENDGAME_MODES + + if gamemode is not None and (gamemode[0] in endGameModes): + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + return + + data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] | (data[3] << 8) + + while (recv_index < recv_item): + item_address = recv_index * 8 + message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + item_address, 8) + is_z3_item = ((message[5] & 0x80) != 0) + masked_part = (message[5] & 0x7F) if is_z3_item else message[5] + item_index = ((message[4] | (masked_part << 8)) >> 3) + (256 if is_z3_item else 0) + + recv_index += 1 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + + from worlds.smz3.TotalSMZ3.Location import locations_start_id + from worlds.smz3 import convertLocSMZ3IDToAPID + location_id = locations_start_id + convertLocSMZ3IDToAPID(item_index) + + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + + data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4) + if data is None: + return + + item_out_ptr = data[2] | (data[3] << 8) + + from worlds.smz3.TotalSMZ3.Item import items_start_id + if item_out_ptr < len(ctx.items_received): + item = ctx.items_received[item_out_ptr] + item_id = item.item - items_start_id + + player_id = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + item_out_ptr * 4, bytes([player_id & 0xFF, (player_id >> 8) & 0xFF, item_id & 0xFF, (item_id >> 8) & 0xFF])) + item_out_ptr += 1 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([item_out_ptr & 0xFF, (item_out_ptr >> 8) & 0xFF])) + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], item_out_ptr, len(ctx.items_received))) + + await snes_flush_writes(ctx) + diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index 753fb556ae..320d506fd2 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -17,6 +17,7 @@ from worlds.smz3.TotalSMZ3.Location import LocationType, locations_start_id, Loc from worlds.smz3.TotalSMZ3.Patch import Patch as TotalSMZ3Patch, getWord, getWordArray from worlds.smz3.TotalSMZ3.WorldState import WorldState from ..AutoWorld import World, AutoLogicRegister, WebWorld +from .Client import SMZ3SNIClient from .Rom import get_base_rom_bytes, SMZ3DeltaPatch from .ips import IPS_Patch from .Options import smz3_options From 700fe8b75e10a2e19e0f9b9986a440c30fff0819 Mon Sep 17 00:00:00 2001 From: Magnemania <89949176+Magnemania@users.noreply.github.com> Date: Wed, 26 Oct 2022 06:24:54 -0400 Subject: [PATCH 07/17] SC2: New Settings, Logic improvements (#1110) * Switched mission item group to a list comprehension to fix missile shuffle errors * Logic for reducing mission and item counts * SC2: Piercing the Shroud/Maw of the Void requirements now DRY * SC2: Logic for All-In, may need further refinement * SC2: Additional mission orders and starting locations * SC2: New Mission Order options for shorter campaigns and smaller item pools * Using location table for hardcoded starter unit * SC2: Options to curate random item pool and control early unit placement * SC2: Proper All-In logic * SC2: Grid, Mini Grid and Blitz mission orders * SC2: Required Tactics and Unit Upgrade options, better connected item handling * SC2: Client compatibility with Grid settings * SC2: Mission rando now uses world random * SC2: Alternate final missions, new logic, fixes * SC2: Handling alternate final missions, identifying final mission on client * SC2: Minor changes to handle edge-case generation failures * SC2: Removed invalid type hints for Python 3.8 * Revert "SC2: Removed invalid type hints for Python 3.8" This reverts commit 7851b9f7a39396c8ee1d85d4e4e46e61e8dc80f6. * SC2: Removed invalid type hints for Python 3.8 * SC2: Removed invalid type hints for Python 3.8 * SC2: Removed invalid type hints for Python 3.8 * SC2: Removed invalid type hints for Python 3.8 * SC2: Changed location loop to enumerate * SC2: Passing category names through slot data * SC2: Cleaned up unnecessary _create_items method * SC2: Removed vestigial extra_locations field from MissionInfo * SC2: Client backwards compatibility * SC2: Fixed item generation issue where item is present in both locked and unlocked inventories * SC2: Removed Missile Turret from defense rating on maps without air * SC2: No logic locations point to same access rule Co-authored-by: michaelasantiago Co-authored-by: Fabian Dill --- Starcraft2Client.py | 51 ++++++- worlds/sc2wol/Items.py | 140 +++++++++++------- worlds/sc2wol/Locations.py | 135 +++++++++-------- worlds/sc2wol/LogicMixin.py | 77 +++++++--- worlds/sc2wol/MissionTables.py | 182 +++++++++++++++++++---- worlds/sc2wol/Options.py | 91 ++++++++++-- worlds/sc2wol/PoolFilter.py | 257 +++++++++++++++++++++++++++++++++ worlds/sc2wol/Regions.py | 127 ++++++++-------- worlds/sc2wol/__init__.py | 91 ++++++++---- 9 files changed, 872 insertions(+), 279 deletions(-) create mode 100644 worlds/sc2wol/PoolFilter.py diff --git a/Starcraft2Client.py b/Starcraft2Client.py index de0a90411e..7431b6ea61 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -155,7 +155,9 @@ class SC2Context(CommonContext): items_handling = 0b111 difficulty = -1 all_in_choice = 0 + mission_order = 0 mission_req_table: typing.Dict[str, MissionInfo] = {} + final_mission: int = 29 announcements = queue.Queue() sc2_run_task: typing.Optional[asyncio.Task] = None missions_unlocked: bool = False # allow launching missions ignoring requirements @@ -180,9 +182,15 @@ class SC2Context(CommonContext): self.difficulty = args["slot_data"]["game_difficulty"] self.all_in_choice = args["slot_data"]["all_in_map"] slot_req_table = args["slot_data"]["mission_req"] + # Maintaining backwards compatibility with older slot data self.mission_req_table = { - mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table + mission: MissionInfo( + **{field: value for field, value in mission_info.items() if field in MissionInfo._fields} + ) + for mission, mission_info in slot_req_table.items() } + self.mission_order = args["slot_data"].get("mission_order", 0) + self.final_mission = args["slot_data"].get("final_mission", 29) self.build_location_to_mission_mapping() @@ -304,7 +312,6 @@ class SC2Context(CommonContext): self.refresh_from_launching = True self.mission_panel.clear_widgets() - if self.ctx.mission_req_table: self.last_checked_locations = self.ctx.checked_locations.copy() self.first_check = False @@ -322,17 +329,20 @@ class SC2Context(CommonContext): for category in categories: category_panel = MissionCategory() + if category.startswith('_'): + category_display_name = '' + else: + category_display_name = category category_panel.add_widget( - Label(text=category, size_hint_y=None, height=50, outline_width=1)) + Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) for mission in categories[category]: text: str = mission tooltip: str = "" - + mission_id: int = self.ctx.mission_req_table[mission].id # Map has uncollected locations if mission in unfinished_missions: text = f"[color=6495ED]{text}[/color]" - elif mission in available_missions: text = f"[color=FFFFFF]{text}[/color]" # Map requirements not met @@ -351,6 +361,16 @@ class SC2Context(CommonContext): remaining_location_names: typing.List[str] = [ self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) if loc in self.ctx.missing_locations] + + if mission_id == self.ctx.final_mission: + if mission in available_missions: + text = f"[color=FFBC95]{mission}[/color]" + else: + text = f"[color=D0C0BE]{mission}[/color]" + if tooltip: + tooltip += "\n" + tooltip += "Final Mission" + if remaining_location_names: if tooltip: tooltip += "\n" @@ -360,7 +380,7 @@ class SC2Context(CommonContext): mission_button = MissionButton(text=text, size_hint_y=None, height=50) mission_button.tooltip_text = tooltip mission_button.bind(on_press=self.mission_callback) - self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button + self.mission_id_to_button[mission_id] = mission_button category_panel.add_widget(mission_button) category_panel.add_widget(Label(text="")) @@ -469,6 +489,9 @@ wol_default_categories = [ "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", "Char", "Char", "Char", "Char" ] +wol_default_category_names = [ + "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char" +] def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]: @@ -586,7 +609,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): if self.can_read_game: if game_state & (1 << 1) and not self.mission_completed: - if self.mission_id != 29: + if self.mission_id != self.ctx.final_mission: print("Mission Completed") await self.ctx.send_msgs( [{"cmd": 'LocationChecks', @@ -742,13 +765,14 @@ def calc_available_missions(ctx: SC2Context, unlocks=None): return available_missions -def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete): +def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): """Returns a bool signifying if the mission has all requirements complete and can be done Arguments: ctx -- instance of SC2Context locations_to_check -- the mission string name to check missions_complete -- an int of how many missions have been completed + mission_path -- a list of missions that have already been checked """ if len(ctx.mission_req_table[mission_name].required_world) >= 1: # A check for when the requirements are being or'd @@ -766,7 +790,18 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete else: req_success = False + # Grid-specific logic (to avoid long path checks and infinite recursion) + if ctx.mission_order in (3, 4): + if req_success: + return True + else: + if req_mission is ctx.mission_req_table[mission_name].required_world[-1]: + return False + else: + continue + # Recursively check required mission to see if it's requirements are met, in case !collect has been done + # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): if not ctx.mission_req_table[mission_name].or_requirements: return False diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index 6bb74076fb..6cb768de58 100644 --- a/worlds/sc2wol/Items.py +++ b/worlds/sc2wol/Items.py @@ -1,5 +1,7 @@ -from BaseClasses import Item, ItemClassification +from BaseClasses import Item, ItemClassification, MultiWorld import typing + +from .Options import get_option_value from .MissionTables import vanilla_mission_req_table @@ -9,6 +11,7 @@ class ItemData(typing.NamedTuple): number: typing.Optional[int] classification: ItemClassification = ItemClassification.useful quantity: int = 1 + parent_item: str = None class StarcraftWoLItem(Item): @@ -48,51 +51,51 @@ item_table = { "Progressive Ship Weapon": ItemData(105 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 8, quantity=3), "Progressive Ship Armor": ItemData(106 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 10, quantity=3), - "Projectile Accelerator (Bunker)": ItemData(200 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 0), - "Neosteel Bunker (Bunker)": ItemData(201 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 1), - "Titanium Housing (Missile Turret)": ItemData(202 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 2, classification=ItemClassification.filler), - "Hellstorm Batteries (Missile Turret)": ItemData(203 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 3), - "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4), - "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5), + "Projectile Accelerator (Bunker)": ItemData(200 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 0, parent_item="Bunker"), + "Neosteel Bunker (Bunker)": ItemData(201 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 1, parent_item="Bunker"), + "Titanium Housing (Missile Turret)": ItemData(202 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 2, classification=ItemClassification.filler, parent_item="Missile Turret"), + "Hellstorm Batteries (Missile Turret)": ItemData(203 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 3, parent_item="Missile Turret"), + "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4, parent_item="SCV"), + "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5, parent_item="SCV"), "Fire-Suppression System (Building)": ItemData(206 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 6, classification=ItemClassification.filler), "Orbital Command (Building)": ItemData(207 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 7), - "Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8), - "Combat Shield (Marine)": ItemData(209 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 9, classification=ItemClassification.progression), - "Advanced Medic Facilities (Medic)": ItemData(210 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 10, classification=ItemClassification.progression), - "Stabilizer Medpacks (Medic)": ItemData(211 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 11, classification=ItemClassification.progression), - "Incinerator Gauntlets (Firebat)": ItemData(212 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 12, classification=ItemClassification.filler), - "Juggernaut Plating (Firebat)": ItemData(213 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 13), - "Concussive Shells (Marauder)": ItemData(214 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 14), - "Kinetic Foam (Marauder)": ItemData(215 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 15), - "U-238 Rounds (Reaper)": ItemData(216 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 16), - "G-4 Clusterbomb (Reaper)": ItemData(217 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 17, classification=ItemClassification.progression), + "Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8, parent_item="Marine"), + "Combat Shield (Marine)": ItemData(209 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 9, classification=ItemClassification.progression, parent_item="Marine"), + "Advanced Medic Facilities (Medic)": ItemData(210 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 10, classification=ItemClassification.progression, parent_item="Medic"), + "Stabilizer Medpacks (Medic)": ItemData(211 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 11, classification=ItemClassification.progression, parent_item="Medic"), + "Incinerator Gauntlets (Firebat)": ItemData(212 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 12, classification=ItemClassification.filler, parent_item="Firebat"), + "Juggernaut Plating (Firebat)": ItemData(213 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 13, parent_item="Firebat"), + "Concussive Shells (Marauder)": ItemData(214 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 14, parent_item="Marauder"), + "Kinetic Foam (Marauder)": ItemData(215 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 15, parent_item="Marauder"), + "U-238 Rounds (Reaper)": ItemData(216 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 16, parent_item="Reaper"), + "G-4 Clusterbomb (Reaper)": ItemData(217 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 17, classification=ItemClassification.progression, parent_item="Reaper"), - "Twin-Linked Flamethrower (Hellion)": ItemData(300 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 0, classification=ItemClassification.filler), - "Thermite Filaments (Hellion)": ItemData(301 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 1), - "Cerberus Mine (Vulture)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler), - "Replenishable Magazine (Vulture)": ItemData(303 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 3, classification=ItemClassification.filler), - "Multi-Lock Weapons System (Goliath)": ItemData(304 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 4), - "Ares-Class Targeting System (Goliath)": ItemData(305 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 5), - "Tri-Lithium Power Cell (Diamondback)": ItemData(306 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 6, classification=ItemClassification.filler), - "Shaped Hull (Diamondback)": ItemData(307 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 7, classification=ItemClassification.filler), - "Maelstrom Rounds (Siege Tank)": ItemData(308 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 8), - "Shaped Blast (Siege Tank)": ItemData(309 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 9), - "Rapid Deployment Tube (Medivac)": ItemData(310 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 10, classification=ItemClassification.filler), - "Advanced Healing AI (Medivac)": ItemData(311 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 11, classification=ItemClassification.filler), - "Tomahawk Power Cells (Wraith)": ItemData(312 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 12, classification=ItemClassification.filler), - "Displacement Field (Wraith)": ItemData(313 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 13, classification=ItemClassification.filler), - "Ripwave Missiles (Viking)": ItemData(314 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 14), - "Phobos-Class Weapons System (Viking)": ItemData(315 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 15), - "Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 16, classification=ItemClassification.filler), - "Shockwave Missile Battery (Banshee)": ItemData(317 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 17), - "Missile Pods (Battlecruiser)": ItemData(318 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 18, classification=ItemClassification.filler), - "Defensive Matrix (Battlecruiser)": ItemData(319 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 19, classification=ItemClassification.filler), - "Ocular Implants (Ghost)": ItemData(320 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 20), - "Crius Suit (Ghost)": ItemData(321 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 21), - "Psionic Lash (Spectre)": ItemData(322 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 22, classification=ItemClassification.progression), - "Nyx-Class Cloaking Module (Spectre)": ItemData(323 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 23), - "330mm Barrage Cannon (Thor)": ItemData(324 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 24, classification=ItemClassification.filler), - "Immortality Protocol (Thor)": ItemData(325 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 25, classification=ItemClassification.filler), + "Twin-Linked Flamethrower (Hellion)": ItemData(300 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 0, classification=ItemClassification.filler, parent_item="Hellion"), + "Thermite Filaments (Hellion)": ItemData(301 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 1, parent_item="Hellion"), + "Cerberus Mine (Vulture)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler, parent_item="Vulture"), + "Replenishable Magazine (Vulture)": ItemData(303 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 3, classification=ItemClassification.filler, parent_item="Vulture"), + "Multi-Lock Weapons System (Goliath)": ItemData(304 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 4, parent_item="Goliath"), + "Ares-Class Targeting System (Goliath)": ItemData(305 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 5, parent_item="Goliath"), + "Tri-Lithium Power Cell (Diamondback)": ItemData(306 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 6, classification=ItemClassification.filler, parent_item="Diamondback"), + "Shaped Hull (Diamondback)": ItemData(307 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 7, classification=ItemClassification.filler, parent_item="Diamondback"), + "Maelstrom Rounds (Siege Tank)": ItemData(308 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 8, classification=ItemClassification.progression, parent_item="Siege Tank"), + "Shaped Blast (Siege Tank)": ItemData(309 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 9, parent_item="Siege Tank"), + "Rapid Deployment Tube (Medivac)": ItemData(310 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 10, classification=ItemClassification.filler, parent_item="Medivac"), + "Advanced Healing AI (Medivac)": ItemData(311 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 11, classification=ItemClassification.filler, parent_item="Medivac"), + "Tomahawk Power Cells (Wraith)": ItemData(312 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 12, classification=ItemClassification.filler, parent_item="Wraith"), + "Displacement Field (Wraith)": ItemData(313 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 13, classification=ItemClassification.filler, parent_item="Wraith"), + "Ripwave Missiles (Viking)": ItemData(314 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 14, parent_item="Viking"), + "Phobos-Class Weapons System (Viking)": ItemData(315 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 15, parent_item="Viking"), + "Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 16, classification=ItemClassification.filler, parent_item="Banshee"), + "Shockwave Missile Battery (Banshee)": ItemData(317 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 17, parent_item="Banshee"), + "Missile Pods (Battlecruiser)": ItemData(318 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 18, classification=ItemClassification.filler, parent_item="Battlecruiser"), + "Defensive Matrix (Battlecruiser)": ItemData(319 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 19, classification=ItemClassification.filler, parent_item="Battlecruiser"), + "Ocular Implants (Ghost)": ItemData(320 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 20, parent_item="Ghost"), + "Crius Suit (Ghost)": ItemData(321 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 21, parent_item="Ghost"), + "Psionic Lash (Spectre)": ItemData(322 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 22, classification=ItemClassification.progression, parent_item="Spectre"), + "Nyx-Class Cloaking Module (Spectre)": ItemData(323 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 23, parent_item="Spectre"), + "330mm Barrage Cannon (Thor)": ItemData(324 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 24, classification=ItemClassification.filler, parent_item="Thor"), + "Immortality Protocol (Thor)": ItemData(325 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 25, classification=ItemClassification.filler, parent_item="Thor"), "Bunker": ItemData(400 + SC2WOL_ITEM_ID_OFFSET, "Building", 0, classification=ItemClassification.progression), "Missile Turret": ItemData(401 + SC2WOL_ITEM_ID_OFFSET, "Building", 1, classification=ItemClassification.progression), @@ -117,16 +120,16 @@ item_table = { "Science Vessel": ItemData(607 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 7, classification=ItemClassification.progression), "Tech Reactor": ItemData(608 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 8), "Orbital Strike": ItemData(609 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 9), - "Shrike Turret": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10), - "Fortified Bunker": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11), - "Planetary Fortress": ItemData(612 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12), - "Perdition Turret": ItemData(613 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 13), + "Shrike Turret": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10, parent_item="Bunker"), + "Fortified Bunker": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11, parent_item="Bunker"), + "Planetary Fortress": ItemData(612 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12, classification=ItemClassification.progression), + "Perdition Turret": ItemData(613 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 13, classification=ItemClassification.progression), "Predator": ItemData(614 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 14, classification=ItemClassification.filler), "Hercules": ItemData(615 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 15, classification=ItemClassification.progression), "Cellular Reactor": ItemData(616 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 16, classification=ItemClassification.filler), "Regenerative Bio-Steel": ItemData(617 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 17, classification=ItemClassification.filler), - "Hive Mind Emulator": ItemData(618 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 18), - "Psi Disrupter": ItemData(619 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 19, classification=ItemClassification.filler), + "Hive Mind Emulator": ItemData(618 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 18, ItemClassification.progression), + "Psi Disrupter": ItemData(619 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 19, classification=ItemClassification.progression), "Zealot": ItemData(700 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 0, classification=ItemClassification.progression), "Stalker": ItemData(701 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 1, classification=ItemClassification.progression), @@ -141,15 +144,33 @@ item_table = { "+15 Starting Minerals": ItemData(800 + SC2WOL_ITEM_ID_OFFSET, "Minerals", 15, quantity=0, classification=ItemClassification.filler), "+15 Starting Vespene": ItemData(801 + SC2WOL_ITEM_ID_OFFSET, "Vespene", 15, quantity=0, classification=ItemClassification.filler), "+2 Starting Supply": ItemData(802 + SC2WOL_ITEM_ID_OFFSET, "Supply", 2, quantity=0, classification=ItemClassification.filler), + + # "Keystone Piece": ItemData(850 + SC2WOL_ITEM_ID_OFFSET, "Goal", 0, quantity=0, classification=ItemClassification.progression_skip_balancing) } -basic_unit: typing.Tuple[str, ...] = ( + +basic_units = { 'Marine', 'Marauder', 'Firebat', 'Hellion', 'Vulture' -) +} + +advanced_basic_units = { + 'Reaper', + 'Goliath', + 'Diamondback', + 'Viking' +} + + +def get_basic_units(world: MultiWorld, player: int) -> typing.Set[str]: + if get_option_value(world, player, 'required_tactics') > 0: + return basic_units.union(advanced_basic_units) + else: + return basic_units + item_name_groups = {} for item, data in item_table.items(): @@ -161,6 +182,22 @@ filler_items: typing.Tuple[str, ...] = ( '+15 Starting Vespene' ) +defense_ratings = { + "Siege Tank": 5, + "Maelstrom Rounds": 2, + "Planetary Fortress": 3, + # Bunker w/ Marine/Marauder: 3, + "Perdition Turret": 2, + "Missile Turret": 2, + "Vulture": 2 +} +zerg_defense_ratings = { + "Perdition Turret": 2, + # Bunker w/ Firebat + "Hive Mind Emulator": 3, + "Psi Disruptor": 3 +} + lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in get_full_item_list().items() if data.code} # Map type to expected int @@ -176,4 +213,5 @@ type_flaggroups: typing.Dict[str, int] = { "Minerals": 8, "Vespene": 9, "Supply": 10, + "Goal": 11 } diff --git a/worlds/sc2wol/Locations.py b/worlds/sc2wol/Locations.py index 14dd25fd52..f778c91be8 100644 --- a/worlds/sc2wol/Locations.py +++ b/worlds/sc2wol/Locations.py @@ -1,5 +1,6 @@ from typing import List, Tuple, Optional, Callable, NamedTuple from BaseClasses import MultiWorld +from .Options import get_option_value from BaseClasses import Location @@ -19,6 +20,7 @@ class LocationData(NamedTuple): def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[LocationData, ...]: # Note: rules which are ended with or True are rules identified as needed later when restricted units is an option + logic_level = get_option_value(world, player, 'required_tactics') location_table: List[LocationData] = [ LocationData("Liberation Day", "Liberation Day: Victory", SC2WOL_LOC_ID_OFFSET + 100), LocationData("Liberation Day", "Liberation Day: First Statue", SC2WOL_LOC_ID_OFFSET + 101), @@ -32,26 +34,33 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Outlaws", "The Outlaws: Rebel Base", SC2WOL_LOC_ID_OFFSET + 201, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Zero Hour", "Zero Hour: Victory", SC2WOL_LOC_ID_OFFSET + 300, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 2 and + (logic_level > 0 or state._sc2wol_has_anti_air(world, player))), LocationData("Zero Hour", "Zero Hour: First Group Rescued", SC2WOL_LOC_ID_OFFSET + 301), LocationData("Zero Hour", "Zero Hour: Second Group Rescued", SC2WOL_LOC_ID_OFFSET + 302, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Zero Hour", "Zero Hour: Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 303, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 2), LocationData("Evacuation", "Evacuation: Victory", SC2WOL_LOC_ID_OFFSET + 400, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Evacuation", "Evacuation: First Chysalis", SC2WOL_LOC_ID_OFFSET + 401), LocationData("Evacuation", "Evacuation: Second Chysalis", SC2WOL_LOC_ID_OFFSET + 402, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Evacuation", "Evacuation: Third Chysalis", SC2WOL_LOC_ID_OFFSET + 403, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Outbreak", "Outbreak: Victory", SC2WOL_LOC_ID_OFFSET + 500, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 4 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Outbreak", "Outbreak: Left Infestor", SC2WOL_LOC_ID_OFFSET + 501, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Outbreak", "Outbreak: Right Infestor", SC2WOL_LOC_ID_OFFSET + 502, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Safe Haven", "Safe Haven: Victory", SC2WOL_LOC_ID_OFFSET + 600, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_competent_anti_air(world, player)), @@ -66,38 +75,48 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L state._sc2wol_has_competent_anti_air(world, player)), LocationData("Haven's Fall", "Haven's Fall: Victory", SC2WOL_LOC_ID_OFFSET + 700, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: North Hive", SC2WOL_LOC_ID_OFFSET + 701, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: East Hive", SC2WOL_LOC_ID_OFFSET + 702, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: South Hive", SC2WOL_LOC_ID_OFFSET + 703, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Smash and Grab", "Smash and Grab: Victory", SC2WOL_LOC_ID_OFFSET + 800, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Smash and Grab", "Smash and Grab: First Relic", SC2WOL_LOC_ID_OFFSET + 801), LocationData("Smash and Grab", "Smash and Grab: Second Relic", SC2WOL_LOC_ID_OFFSET + 802), LocationData("Smash and Grab", "Smash and Grab: Third Relic", SC2WOL_LOC_ID_OFFSET + 803, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Smash and Grab", "Smash and Grab: Fourth Relic", SC2WOL_LOC_ID_OFFSET + 804, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("The Dig", "The Dig: Victory", SC2WOL_LOC_ID_OFFSET + 900, - lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_anti_air(world, player) and - state._sc2wol_has_heavy_defense(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, False) >= 7), LocationData("The Dig", "The Dig: Left Relic", SC2WOL_LOC_ID_OFFSET + 901, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Dig", "The Dig: Right Ground Relic", SC2WOL_LOC_ID_OFFSET + 902, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Dig", "The Dig: Right Cliff Relic", SC2WOL_LOC_ID_OFFSET + 903, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Moebius Factor", "The Moebius Factor: Victory", SC2WOL_LOC_ID_OFFSET + 1000, - lambda state: state._sc2wol_has_air(world, player) and state._sc2wol_has_anti_air(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + (state._sc2wol_has_air(world, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(world, player))), LocationData("The Moebius Factor", "The Moebius Factor: South Rescue", SC2WOL_LOC_ID_OFFSET + 1003, lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Wall Rescue", SC2WOL_LOC_ID_OFFSET + 1004, @@ -109,7 +128,10 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Moebius Factor", "The Moebius Factor: Alive Inside Rescue", SC2WOL_LOC_ID_OFFSET + 1007, lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1008, - lambda state: state._sc2wol_has_air(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + (state._sc2wol_has_air(world, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(world, player))), LocationData("Supernova", "Supernova: Victory", SC2WOL_LOC_ID_OFFSET + 1100, lambda state: state._sc2wol_beats_protoss_deathball(world, player)), LocationData("Supernova", "Supernova: West Relic", SC2WOL_LOC_ID_OFFSET + 1101), @@ -119,37 +141,23 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("Supernova", "Supernova: East Relic", SC2WOL_LOC_ID_OFFSET + 1104, lambda state: state._sc2wol_beats_protoss_deathball(world, player)), LocationData("Maw of the Void", "Maw of the Void: Victory", SC2WOL_LOC_ID_OFFSET + 1200, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: Landing Zone Cleared", SC2WOL_LOC_ID_OFFSET + 1201), LocationData("Maw of the Void", "Maw of the Void: Expansion Prisoners", SC2WOL_LOC_ID_OFFSET + 1202, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: logic_level > 0 or state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: South Close Prisoners", SC2WOL_LOC_ID_OFFSET + 1203, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: logic_level > 0 or state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: South Far Prisoners", SC2WOL_LOC_ID_OFFSET + 1204, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: North Prisoners", SC2WOL_LOC_ID_OFFSET + 1205, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: state._sc2wol_survives_rip_field(world, player)), LocationData("Devil's Playground", "Devil's Playground: Victory", SC2WOL_LOC_ID_OFFSET + 1300, - lambda state: state._sc2wol_has_anti_air(world, player) and ( - state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), + lambda state: logic_level > 0 or + state._sc2wol_has_anti_air(world, player) and ( + state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Devil's Playground", "Devil's Playground: Tosh's Miners", SC2WOL_LOC_ID_OFFSET + 1301), LocationData("Devil's Playground", "Devil's Playground: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1302, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: logic_level > 0 or state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), LocationData("Welcome to the Jungle", "Welcome to the Jungle: Victory", SC2WOL_LOC_ID_OFFSET + 1400, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_competent_anti_air(world, player)), @@ -176,7 +184,8 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Great Train Robbery", "The Great Train Robbery: Mid Defiler", SC2WOL_LOC_ID_OFFSET + 1702), LocationData("The Great Train Robbery", "The Great Train Robbery: South Defiler", SC2WOL_LOC_ID_OFFSET + 1703), LocationData("Cutthroat", "Cutthroat: Victory", SC2WOL_LOC_ID_OFFSET + 1800, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + (logic_level > 0 or state._sc2wol_has_anti_air)), LocationData("Cutthroat", "Cutthroat: Mira Han", SC2WOL_LOC_ID_OFFSET + 1801, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Cutthroat", "Cutthroat: North Relic", SC2WOL_LOC_ID_OFFSET + 1802, @@ -208,40 +217,44 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Media Blitz", "Media Blitz: Science Facility", SC2WOL_LOC_ID_OFFSET + 2004), LocationData("Piercing the Shroud", "Piercing the Shroud: Victory", SC2WOL_LOC_ID_OFFSET + 2100, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Holding Cell Relic", SC2WOL_LOC_ID_OFFSET + 2101), LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk Relic", SC2WOL_LOC_ID_OFFSET + 2102, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: First Escape Relic", SC2WOL_LOC_ID_OFFSET + 2103, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Second Escape Relic", SC2WOL_LOC_ID_OFFSET + 2104, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk ", SC2WOL_LOC_ID_OFFSET + 2105, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Whispers of Doom", "Whispers of Doom: Victory", SC2WOL_LOC_ID_OFFSET + 2200), LocationData("Whispers of Doom", "Whispers of Doom: First Hatchery", SC2WOL_LOC_ID_OFFSET + 2201), LocationData("Whispers of Doom", "Whispers of Doom: Second Hatchery", SC2WOL_LOC_ID_OFFSET + 2202), LocationData("Whispers of Doom", "Whispers of Doom: Third Hatchery", SC2WOL_LOC_ID_OFFSET + 2203), LocationData("A Sinister Turn", "A Sinister Turn: Victory", SC2WOL_LOC_ID_OFFSET + 2300, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), - LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301), - LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302), + LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), + LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), LocationData("A Sinister Turn", "A Sinister Turn: Templar Archives", SC2WOL_LOC_ID_OFFSET + 2303, lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("Echoes of the Future", "Echoes of the Future: Victory", SC2WOL_LOC_ID_OFFSET + 2400, - lambda state: state._sc2wol_has_protoss_medium_units(world, player)), + lambda state: logic_level > 0 or state._sc2wol_has_protoss_medium_units(world, player)), LocationData("Echoes of the Future", "Echoes of the Future: Close Obelisk", SC2WOL_LOC_ID_OFFSET + 2401), LocationData("Echoes of the Future", "Echoes of the Future: West Obelisk", SC2WOL_LOC_ID_OFFSET + 2402, - lambda state: state._sc2wol_has_protoss_common_units(world, player)), + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), LocationData("In Utter Darkness", "In Utter Darkness: Defeat", SC2WOL_LOC_ID_OFFSET + 2500), LocationData("In Utter Darkness", "In Utter Darkness: Protoss Archive", SC2WOL_LOC_ID_OFFSET + 2501, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), LocationData("In Utter Darkness", "In Utter Darkness: Kills", SC2WOL_LOC_ID_OFFSET + 2502, lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("Gates of Hell", "Gates of Hell: Victory", SC2WOL_LOC_ID_OFFSET + 2600, - lambda state: state._sc2wol_has_competent_comp(world, player)), + lambda state: state._sc2wol_has_competent_comp(world, player) and + state._sc2wol_defense_rating(world, player, True) > 6), LocationData("Gates of Hell", "Gates of Hell: Large Army", SC2WOL_LOC_ID_OFFSET + 2601, - lambda state: state._sc2wol_has_competent_comp(world, player)), + lambda state: state._sc2wol_has_competent_comp(world, player) and + state._sc2wol_defense_rating(world, player, True) > 6), LocationData("Belly of the Beast", "Belly of the Beast: Victory", SC2WOL_LOC_ID_OFFSET + 2700), LocationData("Belly of the Beast", "Belly of the Beast: First Charge", SC2WOL_LOC_ID_OFFSET + 2701), LocationData("Belly of the Beast", "Belly of the Beast: Second Charge", SC2WOL_LOC_ID_OFFSET + 2702), @@ -258,15 +271,19 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Shatter the Sky", "Shatter the Sky: Leviathan", SC2WOL_LOC_ID_OFFSET + 2805, lambda state: state._sc2wol_has_competent_comp(world, player)), - LocationData("All-In", "All-In: Victory", None) + LocationData("All-In", "All-In: Victory", None, + lambda state: state._sc2wol_final_mission_requirements(world, player)) ] beat_events = [] - for location_data in location_table: + for i, location_data in enumerate(location_table): + # Removing all item-based logic on No Logic + if logic_level == 2: + location_table[i] = location_data._replace(rule=Location.access_rule) + # Generating Beat event locations if location_data.name.endswith((": Victory", ": Defeat")): beat_events.append( location_data._replace(name="Beat " + location_data.name.rsplit(": ", 1)[0], code=None) ) - return tuple(location_table + beat_events) diff --git a/worlds/sc2wol/LogicMixin.py b/worlds/sc2wol/LogicMixin.py index 52bb6b09a8..1de8295970 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -1,31 +1,43 @@ from BaseClasses import MultiWorld from worlds.AutoWorld import LogicMixin +from .Options import get_option_value +from .Items import get_basic_units, defense_ratings, zerg_defense_ratings class SC2WoLLogic(LogicMixin): def _sc2wol_has_common_unit(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Marauder', 'Firebat', 'Hellion', 'Vulture'}, player) - - def _sc2wol_has_bunker_unit(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Marauder'}, player) + return self.has_any(get_basic_units(world, player), player) def _sc2wol_has_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Viking', 'Wraith', 'Banshee'}, player) or \ - self.has_any({'Hercules', 'Medivac'}, player) and self._sc2wol_has_common_unit(world, player) + return self.has_any({'Viking', 'Wraith', 'Banshee'}, player) or get_option_value(world, player, 'required_tactics') > 0 \ + and self.has_any({'Hercules', 'Medivac'}, player) and self._sc2wol_has_common_unit(world, player) def _sc2wol_has_air_anti_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Viking', 'Wraith'}, player) + return self.has('Viking', player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has('Wraith', player) def _sc2wol_has_competent_anti_air(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Marine', 'Goliath'}, player) or self._sc2wol_has_air_anti_air(world, player) def _sc2wol_has_anti_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser'}, player) or self._sc2wol_has_competent_anti_air(world, player) + return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Wraith'}, player) \ + or self._sc2wol_has_competent_anti_air(world, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) - def _sc2wol_has_heavy_defense(self, world: MultiWorld, player: int) -> bool: - return (self.has_any({'Siege Tank', 'Vulture'}, player) or - self.has('Bunker', player) and self._sc2wol_has_bunker_unit(world, player)) and \ - self._sc2wol_has_anti_air(world, player) + def _sc2wol_defense_rating(self, world: MultiWorld, player: int, zerg_enemy: bool, air_enemy: bool = True) -> bool: + defense_score = sum((defense_ratings[item] for item in defense_ratings if self.has(item, player))) + if self.has_any({'Marine', 'Marauder'}, player) and self.has('Bunker', player): + defense_score += 3 + if zerg_enemy: + defense_score += sum((zerg_defense_ratings[item] for item in zerg_defense_ratings if self.has(item, player))) + if self.has('Firebat', player) and self.has('Bunker', player): + defense_score += 2 + if not air_enemy and self.has('Missile Turret', player): + defense_score -= defense_ratings['Missile Turret'] + # Advanced Tactics bumps defense rating requirements down by 2 + if get_option_value(world, player, 'required_tactics') > 0: + defense_score += 2 + return defense_score def _sc2wol_has_competent_comp(self, world: MultiWorld, player: int) -> bool: return (self.has('Marine', player) or self.has('Marauder', player) and @@ -35,25 +47,50 @@ class SC2WoLLogic(LogicMixin): self.has('Siege Tank', player) and self._sc2wol_has_competent_anti_air(world, player) def _sc2wol_has_train_killers(self, world: MultiWorld, player: int) -> bool: - return (self.has_any({'Siege Tank', 'Diamondback'}, player) or - self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player) - or self.has('Marauders', player)) + return (self.has_any({'Siege Tank', 'Diamondback', 'Marauder'}, player) or get_option_value(world, player, 'required_tactics') > 0 + and self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player)) def _sc2wol_able_to_rescue(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) + return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) or get_option_value(world, player, 'required_tactics') > 0 def _sc2wol_has_protoss_common_units(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) + return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) def _sc2wol_has_protoss_medium_units(self, world: MultiWorld, player: int) -> bool: return self._sc2wol_has_protoss_common_units(world, player) and \ - self.has_any({'Stalker', 'Void Ray', 'Phoenix', 'Carrier'}, player) + self.has_any({'Stalker', 'Void Ray', 'Phoenix', 'Carrier'}, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) def _sc2wol_beats_protoss_deathball(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Banshee', 'Battlecruiser'}, player) and self._sc2wol_has_competent_anti_air or \ self._sc2wol_has_competent_comp(world, player) and self._sc2wol_has_air_anti_air(world, player) + def _sc2wol_has_mm_upgrade(self, world: MultiWorld, player: int) -> bool: + return self.has_any({"Combat Shield (Marine)", "Stabilizer Medpacks (Medic)"}, player) + + def _sc2wol_survives_rip_field(self, world: MultiWorld, player: int) -> bool: + return self.has("Battlecruiser", player) or \ + self._sc2wol_has_air(world, player) and \ + self._sc2wol_has_competent_anti_air(world, player) and \ + self.has("Science Vessel", player) + + def _sc2wol_has_nukes(self, world: MultiWorld, player: int) -> bool: + return get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) + + def _sc2wol_final_mission_requirements(self, world: MultiWorld, player: int): + defense_rating = self._sc2wol_defense_rating(world, player, True) + beats_kerrigan = self.has_any({'Marine', 'Banshee', 'Ghost'}, player) or get_option_value(world, player, 'required_tactics') > 0 + if get_option_value(world, player, 'all_in_map') == 0: + # Ground + if self.has_any({'Battlecruiser', 'Banshee'}, player): + defense_rating += 3 + return defense_rating >= 12 and beats_kerrigan + else: + # Air + return defense_rating >= 8 and beats_kerrigan \ + and self.has_any({'Viking', 'Battlecruiser'}, player) \ + and self.has_any({'Hive Mind Emulator', 'Psi Disruptor', 'Missile Turret'}, player) + def _sc2wol_cleared_missions(self, world: MultiWorld, player: int, mission_count: int) -> bool: return self.has_group("Missions", player, mission_count) - - diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index 4f1b1157ec..8d06944662 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -1,4 +1,7 @@ -from typing import NamedTuple, Dict, List +from typing import NamedTuple, Dict, List, Set + +from BaseClasses import MultiWorld +from .Options import get_option_value no_build_regions_list = ["Liberation Day", "Breakout", "Ghost of a Chance", "Piercing the Shroud", "Whispers of Doom", "Belly of the Beast"] @@ -12,7 +15,6 @@ hard_regions_list = ["Maw of the Void", "Engine of Destruction", "In Utter Darkn class MissionInfo(NamedTuple): id: int - extra_locations: int required_world: List[int] category: str number: int = 0 # number of worlds need beaten @@ -62,38 +64,156 @@ vanilla_shuffle_order = [ FillMission("all_in", [26, 27], "Char", completion_critical=True, or_requirements=True) ] +mini_campaign_order = [ + FillMission("no_build", [-1], "Mar Sara", completion_critical=True), + FillMission("easy", [0], "Colonist"), + FillMission("medium", [1], "Colonist"), + FillMission("medium", [0], "Artifact", completion_critical=True), + FillMission("medium", [3], "Artifact", number=4, completion_critical=True), + FillMission("hard", [4], "Artifact", number=8, completion_critical=True), + FillMission("medium", [0], "Covert", number=2), + FillMission("hard", [6], "Covert"), + FillMission("medium", [0], "Rebellion", number=3), + FillMission("hard", [8], "Rebellion"), + FillMission("medium", [4], "Prophecy"), + FillMission("hard", [10], "Prophecy"), + FillMission("hard", [5], "Char", completion_critical=True), + FillMission("hard", [5], "Char", completion_critical=True), + FillMission("all_in", [12, 13], "Char", completion_critical=True, or_requirements=True) +] + +gauntlet_order = [ + FillMission("no_build", [-1], "I", completion_critical=True), + FillMission("easy", [0], "II", completion_critical=True), + FillMission("medium", [1], "III", completion_critical=True), + FillMission("medium", [2], "IV", completion_critical=True), + FillMission("hard", [3], "V", completion_critical=True), + FillMission("hard", [4], "VI", completion_critical=True), + FillMission("all_in", [5], "Final", completion_critical=True) +] + +grid_order = [ + FillMission("no_build", [-1], "_1"), + FillMission("medium", [0], "_1"), + FillMission("medium", [1, 6, 3], "_1", or_requirements=True), + FillMission("hard", [2, 7], "_1", or_requirements=True), + FillMission("easy", [0], "_2"), + FillMission("medium", [1, 4], "_2", or_requirements=True), + FillMission("hard", [2, 5, 10, 7], "_2", or_requirements=True), + FillMission("hard", [3, 6, 11], "_2", or_requirements=True), + FillMission("medium", [4, 9, 12], "_3", or_requirements=True), + FillMission("hard", [5, 8, 10, 13], "_3", or_requirements=True), + FillMission("hard", [6, 9, 11, 14], "_3", or_requirements=True), + FillMission("hard", [7, 10], "_3", or_requirements=True), + FillMission("hard", [8, 13], "_4", or_requirements=True), + FillMission("hard", [9, 12, 14], "_4", or_requirements=True), + FillMission("hard", [10, 13], "_4", or_requirements=True), + FillMission("all_in", [11, 14], "_4", or_requirements=True) +] + +mini_grid_order = [ + FillMission("no_build", [-1], "_1"), + FillMission("medium", [0], "_1"), + FillMission("medium", [1, 5], "_1", or_requirements=True), + FillMission("easy", [0], "_2"), + FillMission("medium", [1, 3], "_2", or_requirements=True), + FillMission("hard", [2, 4], "_2", or_requirements=True), + FillMission("medium", [3, 7], "_3", or_requirements=True), + FillMission("hard", [4, 6], "_3", or_requirements=True), + FillMission("all_in", [5, 7], "_3", or_requirements=True) +] + +blitz_order = [ + FillMission("no_build", [-1], "I"), + FillMission("easy", [-1], "I"), + FillMission("medium", [0, 1], "II", number=1, or_requirements=True), + FillMission("medium", [0, 1], "II", number=1, or_requirements=True), + FillMission("medium", [0, 1], "III", number=2, or_requirements=True), + FillMission("medium", [0, 1], "III", number=2, or_requirements=True), + FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), + FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), + FillMission("hard", [0, 1], "V", number=4, or_requirements=True), + FillMission("hard", [0, 1], "V", number=4, or_requirements=True), + FillMission("hard", [0, 1], "Final", number=5, or_requirements=True), + FillMission("all_in", [0, 1], "Final", number=5, or_requirements=True) +] + +mission_orders = [vanilla_shuffle_order, vanilla_shuffle_order, mini_campaign_order, grid_order, mini_grid_order, blitz_order, gauntlet_order] + vanilla_mission_req_table = { - "Liberation Day": MissionInfo(1, 7, [], "Mar Sara", completion_critical=True), - "The Outlaws": MissionInfo(2, 2, [1], "Mar Sara", completion_critical=True), - "Zero Hour": MissionInfo(3, 4, [2], "Mar Sara", completion_critical=True), - "Evacuation": MissionInfo(4, 4, [3], "Colonist"), - "Outbreak": MissionInfo(5, 3, [4], "Colonist"), - "Safe Haven": MissionInfo(6, 4, [5], "Colonist", number=7), - "Haven's Fall": MissionInfo(7, 4, [5], "Colonist", number=7), - "Smash and Grab": MissionInfo(8, 5, [3], "Artifact", completion_critical=True), - "The Dig": MissionInfo(9, 4, [8], "Artifact", number=8, completion_critical=True), - "The Moebius Factor": MissionInfo(10, 9, [9], "Artifact", number=11, completion_critical=True), - "Supernova": MissionInfo(11, 5, [10], "Artifact", number=14, completion_critical=True), - "Maw of the Void": MissionInfo(12, 6, [11], "Artifact", completion_critical=True), - "Devil's Playground": MissionInfo(13, 3, [3], "Covert", number=4), - "Welcome to the Jungle": MissionInfo(14, 4, [13], "Covert"), - "Breakout": MissionInfo(15, 3, [14], "Covert", number=8), - "Ghost of a Chance": MissionInfo(16, 6, [14], "Covert", number=8), - "The Great Train Robbery": MissionInfo(17, 4, [3], "Rebellion", number=6), - "Cutthroat": MissionInfo(18, 5, [17], "Rebellion"), - "Engine of Destruction": MissionInfo(19, 6, [18], "Rebellion"), - "Media Blitz": MissionInfo(20, 5, [19], "Rebellion"), - "Piercing the Shroud": MissionInfo(21, 6, [20], "Rebellion"), - "Whispers of Doom": MissionInfo(22, 4, [9], "Prophecy"), - "A Sinister Turn": MissionInfo(23, 4, [22], "Prophecy"), - "Echoes of the Future": MissionInfo(24, 3, [23], "Prophecy"), - "In Utter Darkness": MissionInfo(25, 3, [24], "Prophecy"), - "Gates of Hell": MissionInfo(26, 2, [12], "Char", completion_critical=True), - "Belly of the Beast": MissionInfo(27, 4, [26], "Char", completion_critical=True), - "Shatter the Sky": MissionInfo(28, 5, [26], "Char", completion_critical=True), - "All-In": MissionInfo(29, -1, [27, 28], "Char", completion_critical=True, or_requirements=True) + "Liberation Day": MissionInfo(1, [], "Mar Sara", completion_critical=True), + "The Outlaws": MissionInfo(2, [1], "Mar Sara", completion_critical=True), + "Zero Hour": MissionInfo(3, [2], "Mar Sara", completion_critical=True), + "Evacuation": MissionInfo(4, [3], "Colonist"), + "Outbreak": MissionInfo(5, [4], "Colonist"), + "Safe Haven": MissionInfo(6, [5], "Colonist", number=7), + "Haven's Fall": MissionInfo(7, [5], "Colonist", number=7), + "Smash and Grab": MissionInfo(8, [3], "Artifact", completion_critical=True), + "The Dig": MissionInfo(9, [8], "Artifact", number=8, completion_critical=True), + "The Moebius Factor": MissionInfo(10, [9], "Artifact", number=11, completion_critical=True), + "Supernova": MissionInfo(11, [10], "Artifact", number=14, completion_critical=True), + "Maw of the Void": MissionInfo(12, [11], "Artifact", completion_critical=True), + "Devil's Playground": MissionInfo(13, [3], "Covert", number=4), + "Welcome to the Jungle": MissionInfo(14, [13], "Covert"), + "Breakout": MissionInfo(15, [14], "Covert", number=8), + "Ghost of a Chance": MissionInfo(16, [14], "Covert", number=8), + "The Great Train Robbery": MissionInfo(17, [3], "Rebellion", number=6), + "Cutthroat": MissionInfo(18, [17], "Rebellion"), + "Engine of Destruction": MissionInfo(19, [18], "Rebellion"), + "Media Blitz": MissionInfo(20, [19], "Rebellion"), + "Piercing the Shroud": MissionInfo(21, [20], "Rebellion"), + "Whispers of Doom": MissionInfo(22, [9], "Prophecy"), + "A Sinister Turn": MissionInfo(23, [22], "Prophecy"), + "Echoes of the Future": MissionInfo(24, [23], "Prophecy"), + "In Utter Darkness": MissionInfo(25, [24], "Prophecy"), + "Gates of Hell": MissionInfo(26, [12], "Char", completion_critical=True), + "Belly of the Beast": MissionInfo(27, [26], "Char", completion_critical=True), + "Shatter the Sky": MissionInfo(28, [26], "Char", completion_critical=True), + "All-In": MissionInfo(29, [27, 28], "Char", completion_critical=True, or_requirements=True) } lookup_id_to_mission: Dict[int, str] = { data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} + +no_build_starting_mission_locations = { + "Liberation Day": "Liberation Day: Victory", + "Breakout": "Breakout: Victory", + "Ghost of a Chance": "Ghost of a Chance: Victory", + "Piercing the Shroud": "Piercing the Shroud: Victory", + "Whispers of Doom": "Whispers of Doom: Victory", + "Belly of the Beast": "Belly of the Beast: Victory", +} + +build_starting_mission_locations = { + "Zero Hour": "Zero Hour: First Group Rescued", + "Evacuation": "Evacuation: First Chysalis", + "Devil's Playground": "Devil's Playground: Tosh's Miners" +} + +advanced_starting_mission_locations = { + "Smash and Grab": "Smash and Grab: First Relic", + "The Great Train Robbery": "The Great Train Robbery: North Defiler" +} + + +def get_starting_mission_locations(world: MultiWorld, player: int) -> Set[str]: + if get_option_value(world, player, 'shuffle_no_build') or get_option_value(world, player, 'mission_order') < 2: + # Always start with a no-build mission unless explicitly relegating them + # Vanilla and Vanilla Shuffled always start with a no-build even when relegated + return no_build_starting_mission_locations + elif get_option_value(world, player, 'required_tactics') > 0: + # Advanced Tactics/No Logic add more starting missions to the pool + return {**build_starting_mission_locations, **advanced_starting_mission_locations} + else: + # Standard starting missions when relegate is on + return build_starting_mission_locations + + +alt_final_mission_locations = { + "Maw of the Void": "Maw of the Void: Victory", + "Engine of Destruction": "Engine of Destruction: Victory", + "Supernova": "Supernova: Victory", + "Gates of Hell": "Gates of Hell: Victory", + "Shatter the Sky": "Shatter the Sky: Victory" +} \ No newline at end of file diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index efd0872527..9cd86f2c0b 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -1,6 +1,6 @@ from typing import Dict from BaseClasses import MultiWorld -from Options import Choice, Option, DefaultOnToggle +from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range class GameDifficulty(Choice): @@ -36,25 +36,75 @@ class AllInMap(Choice): class MissionOrder(Choice): - """Determines the order the missions are played in. - Vanilla: Keeps the standard mission order and branching from the WoL Campaign. - Vanilla Shuffled: Keeps same branching paths from the WoL Campaign but randomizes the order of missions within.""" + """Determines the order the missions are played in. The last three mission orders end in a random mission. + Vanilla (29): Keeps the standard mission order and branching from the WoL Campaign. + Vanilla Shuffled (29): Keeps same branching paths from the WoL Campaign but randomizes the order of missions within. + Mini Campaign (15): Shorter version of the campaign with randomized missions and optional branches. + Grid (16): A 4x4 grid of random missions. Start at the top-left and forge a path towards All-In. + Mini Grid (9): A 3x3 version of Grid. Complete the bottom-right mission to win. + Blitz (12): 12 random missions that open up very quickly. Complete the bottom-right mission to win. + Gauntlet (7): Linear series of 7 random missions to complete the campaign.""" display_name = "Mission Order" option_vanilla = 0 option_vanilla_shuffled = 1 + option_mini_campaign = 2 + option_grid = 3 + option_mini_grid = 4 + option_blitz = 5 + option_gauntlet = 6 class ShuffleProtoss(DefaultOnToggle): - """Determines if the 3 protoss missions are included in the shuffle if Vanilla Shuffled is enabled. If this is - not the 3 protoss missions will stay in their vanilla order in the mission order making them optional to complete - the game.""" + """Determines if the 3 protoss missions are included in the shuffle if Vanilla mission order is not enabled. + If turned off with Vanilla Shuffled, the 3 protoss missions will be in their normal position on the Prophecy chain if not shuffled. + If turned off with reduced mission settings, the 3 protoss missions will not appear and Protoss units are removed from the pool.""" display_name = "Shuffle Protoss Missions" -class RelegateNoBuildMissions(DefaultOnToggle): - """If enabled, all no build missions besides the needed first one will be placed at the end of optional routes so - that none of them become required to complete the game. Only takes effect if mission order is not set to vanilla.""" - display_name = "Relegate No-Build Missions" +class ShuffleNoBuild(DefaultOnToggle): + """Determines if the 5 no-build missions are included in the shuffle if Vanilla mission order is not enabled. + If turned off with Vanilla Shuffled, one no-build mission will be placed as the first mission and the rest will be placed at the end of optional routes. + If turned off with reduced mission settings, the 5 no-build missions will not appear.""" + display_name = "Shuffle No-Build Missions" + + +class EarlyUnit(DefaultOnToggle): + """Guarantees that the first mission will contain a unit.""" + display_name = "Early Unit" + + +class RequiredTactics(Choice): + """Determines the maximum tactical difficulty of the seed (separate from mission difficulty). Higher settings increase randomness. + Standard: All missions can be completed with good micro and macro. + Advanced: Completing missions may require relying on starting units and micro-heavy units. + No Logic: Units and upgrades may be placed anywhere. LIKELY TO RENDER THE RUN IMPOSSIBLE ON HARDER DIFFICULTIES!""" + display_name = "Required Tactics" + option_standard = 0 + option_advanced = 1 + option_no_logic = 2 + + +class UnitsAlwaysHaveUpgrades(DefaultOnToggle): + """If turned on, both upgrades will be present for each unit and structure in the seed. + This usually results in fewer units.""" + display_name = "Units Always Have Upgrades" + + +class LockedItems(ItemSet): + """Guarantees that these items will be unlockable""" + display_name = "Locked Items" + + +class ExcludedItems(ItemSet): + """Guarantees that these items will not be unlockable""" + display_name = "Excluded Items" + + +class ExcludedMissions(OptionSet): + """Guarantees that these missions will not appear in the campaign + Only applies on shortened mission orders. + It may be impossible to build a valid campaign if too many missions are excluded.""" + display_name = "Excluded Missions" # noinspection PyTypeChecker @@ -65,14 +115,29 @@ sc2wol_options: Dict[str, Option] = { "all_in_map": AllInMap, "mission_order": MissionOrder, "shuffle_protoss": ShuffleProtoss, - "relegate_no_build": RelegateNoBuildMissions + "shuffle_no_build": ShuffleNoBuild, + "early_unit": EarlyUnit, + "required_tactics": RequiredTactics, + "units_always_have_upgrades": UnitsAlwaysHaveUpgrades, + "locked_items": LockedItems, + "excluded_items": ExcludedItems, + "excluded_missions": ExcludedMissions } def get_option_value(world: MultiWorld, player: int, name: str) -> int: option = getattr(world, name, None) - if option == None: + if option is None: return 0 return int(option[player].value) + + +def get_option_set_value(world: MultiWorld, player: int, name: str) -> set: + option = getattr(world, name, None) + + if option is None: + return set() + + return option[player].value diff --git a/worlds/sc2wol/PoolFilter.py b/worlds/sc2wol/PoolFilter.py new file mode 100644 index 0000000000..5b6970e721 --- /dev/null +++ b/worlds/sc2wol/PoolFilter.py @@ -0,0 +1,257 @@ +from typing import Callable, Dict, List, Set +from BaseClasses import MultiWorld, ItemClassification, Item, Location +from .Items import item_table +from .MissionTables import no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list,\ + mission_orders, get_starting_mission_locations, MissionInfo, vanilla_mission_req_table, alt_final_mission_locations +from .Options import get_option_value, get_option_set_value +from .LogicMixin import SC2WoLLogic + +# Items with associated upgrades +UPGRADABLE_ITEMS = [ + "Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre", + "Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", + "Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", + "Bunker", "Missile Turret" +] + +BARRACKS_UNITS = {"Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre"} +FACTORY_UNITS = {"Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", "Predator"} +STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "Hercules", "Science Vessel", "Raven"} + +PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} + + +def filter_missions(world: MultiWorld, player: int) -> Dict[str, List[str]]: + """ + Returns a semi-randomly pruned tuple of no-build, easy, medium, and hard mission sets + """ + + mission_order_type = get_option_value(world, player, "mission_order") + shuffle_protoss = get_option_value(world, player, "shuffle_protoss") + excluded_missions = set(get_option_set_value(world, player, "excluded_missions")) + invalid_mission_names = excluded_missions.difference(vanilla_mission_req_table.keys()) + if invalid_mission_names: + raise Exception("Error in locked_missions - the following are not valid mission names: " + ", ".join(invalid_mission_names)) + mission_count = len(mission_orders[mission_order_type]) - 1 + # Vanilla and Vanilla Shuffled use the entire mission pool + if mission_count == 28: + return { + "no_build": no_build_regions_list[:], + "easy": easy_regions_list[:], + "medium": medium_regions_list[:], + "hard": hard_regions_list[:], + "all_in": ["All-In"] + } + + mission_pools = [ + [], + easy_regions_list, + medium_regions_list, + hard_regions_list + ] + # Omitting Protoss missions if not shuffling protoss + if not shuffle_protoss: + excluded_missions = excluded_missions.union(PROTOSS_REGIONS) + # Replacing All-In on low mission counts + if mission_count < 14: + final_mission = world.random.choice([mission for mission in alt_final_mission_locations.keys() if mission not in excluded_missions]) + excluded_missions.add(final_mission) + else: + final_mission = 'All-In' + # Yaml settings determine which missions can be placed in the first slot + mission_pools[0] = [mission for mission in get_starting_mission_locations(world, player).keys() if mission not in excluded_missions] + # Removing the new no-build missions from their original sets + for i in range(1, len(mission_pools)): + mission_pools[i] = [mission for mission in mission_pools[i] if mission not in excluded_missions.union(mission_pools[0])] + # If the first mission is a build mission, there may not be enough locations to reach Outbreak as a second mission + if not get_option_value(world, player, 'shuffle_no_build'): + # Swapping Outbreak and The Great Train Robbery + if "Outbreak" in mission_pools[1]: + mission_pools[1].remove("Outbreak") + mission_pools[2].append("Outbreak") + if "The Great Train Robbery" in mission_pools[2]: + mission_pools[2].remove("The Great Train Robbery") + mission_pools[1].append("The Great Train Robbery") + # Removing random missions from each difficulty set in a cycle + set_cycle = 0 + current_count = sum(len(mission_pool) for mission_pool in mission_pools) + + if current_count < mission_count: + raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") + while current_count > mission_count: + if set_cycle == 4: + set_cycle = 0 + # Must contain at least one mission per set + mission_pool = mission_pools[set_cycle] + if len(mission_pool) <= 1: + if all(len(mission_pool) <= 1 for mission_pool in mission_pools): + raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") + else: + mission_pool.remove(world.random.choice(mission_pool)) + current_count -= 1 + set_cycle += 1 + + return { + "no_build": mission_pools[0], + "easy": mission_pools[1], + "medium": mission_pools[2], + "hard": mission_pools[3], + "all_in": [final_mission] + } + + +def get_item_upgrades(inventory: List[Item], parent_item: Item or str): + item_name = parent_item.name if isinstance(parent_item, Item) else parent_item + return [ + inv_item for inv_item in inventory + if item_table[inv_item.name].parent_item == item_name + ] + + +class ValidInventory: + + def has(self, item: str, player: int): + return item in self.logical_inventory + + def has_any(self, items: Set[str], player: int): + return any(item in self.logical_inventory for item in items) + + def has_all(self, items: Set[str], player: int): + return all(item in self.logical_inventory for item in items) + + def has_units_per_structure(self) -> bool: + return len(BARRACKS_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure and \ + len(FACTORY_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure and \ + len(STARPORT_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure + + def generate_reduced_inventory(self, inventory_size: int, mission_requirements: List[Callable]) -> List[Item]: + """Attempts to generate a reduced inventory that can fulfill the mission requirements.""" + inventory = list(self.item_pool) + locked_items = list(self.locked_items) + self.logical_inventory = { + item.name for item in inventory + locked_items + self.existing_items + if item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing) + } + requirements = mission_requirements + cascade_keys = self.cascade_removal_map.keys() + units_always_have_upgrades = get_option_value(self.world, self.player, "units_always_have_upgrades") + if self.min_units_per_structure > 0: + requirements.append(lambda state: state.has_units_per_structure()) + + def attempt_removal(item: Item) -> bool: + # If item can be removed and has associated items, remove them as well + inventory.remove(item) + # Only run logic checks when removing logic items + if item.name in self.logical_inventory: + self.logical_inventory.remove(item.name) + if not all(requirement(self) for requirement in requirements): + # If item cannot be removed, lock or revert + self.logical_inventory.add(item.name) + locked_items.append(item) + return False + return True + + while len(inventory) + len(locked_items) > inventory_size: + if len(inventory) == 0: + raise Exception("Reduced item pool generation failed - not enough locations available to place items.") + # Select random item from removable items + item = self.world.random.choice(inventory) + # Cascade removals to associated items + if item in cascade_keys: + items_to_remove = self.cascade_removal_map[item] + transient_items = [] + while len(items_to_remove) > 0: + item_to_remove = items_to_remove.pop() + if item_to_remove not in inventory: + continue + success = attempt_removal(item_to_remove) + if success: + transient_items.append(item_to_remove) + elif units_always_have_upgrades: + # Lock all associated items if any of them cannot be removed + transient_items += items_to_remove + for transient_item in transient_items: + if transient_item not in inventory and transient_item not in locked_items: + locked_items += transient_item + if transient_item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing): + self.logical_inventory.add(transient_item.name) + break + else: + attempt_removal(item) + + return inventory + locked_items + + def _read_logic(self): + self._sc2wol_has_common_unit = lambda world, player: SC2WoLLogic._sc2wol_has_common_unit(self, world, player) + self._sc2wol_has_air = lambda world, player: SC2WoLLogic._sc2wol_has_air(self, world, player) + self._sc2wol_has_air_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_air_anti_air(self, world, player) + self._sc2wol_has_competent_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_competent_anti_air(self, world, player) + self._sc2wol_has_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_anti_air(self, world, player) + self._sc2wol_defense_rating = lambda world, player, zerg_enemy, air_enemy=False: SC2WoLLogic._sc2wol_defense_rating(self, world, player, zerg_enemy, air_enemy) + self._sc2wol_has_competent_comp = lambda world, player: SC2WoLLogic._sc2wol_has_competent_comp(self, world, player) + self._sc2wol_has_train_killers = lambda world, player: SC2WoLLogic._sc2wol_has_train_killers(self, world, player) + self._sc2wol_able_to_rescue = lambda world, player: SC2WoLLogic._sc2wol_able_to_rescue(self, world, player) + self._sc2wol_beats_protoss_deathball = lambda world, player: SC2WoLLogic._sc2wol_beats_protoss_deathball(self, world, player) + self._sc2wol_survives_rip_field = lambda world, player: SC2WoLLogic._sc2wol_survives_rip_field(self, world, player) + self._sc2wol_has_protoss_common_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_common_units(self, world, player) + self._sc2wol_has_protoss_medium_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_medium_units(self, world, player) + self._sc2wol_has_mm_upgrade = lambda world, player: SC2WoLLogic._sc2wol_has_mm_upgrade(self, world, player) + self._sc2wol_final_mission_requirements = lambda world, player: SC2WoLLogic._sc2wol_final_mission_requirements(self, world, player) + + def __init__(self, world: MultiWorld, player: int, + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item], + has_protoss: bool): + self.world = world + self.player = player + self.logical_inventory = set() + self.locked_items = locked_items[:] + self.existing_items = existing_items + self._read_logic() + # Initial filter of item pool + self.item_pool = [] + item_quantities: dict[str, int] = dict() + # Inventory restrictiveness based on number of missions with checks + mission_order_type = get_option_value(self.world, self.player, "mission_order") + mission_count = len(mission_orders[mission_order_type]) - 1 + self.min_units_per_structure = int(mission_count / 7) + min_upgrades = 1 if mission_count < 10 else 2 + for item in item_pool: + item_info = item_table[item.name] + if item_info.type == "Upgrade": + # Locking upgrades based on mission duration + if item.name not in item_quantities: + item_quantities[item.name] = 0 + item_quantities[item.name] += 1 + if item_quantities[item.name] < min_upgrades: + self.locked_items.append(item) + else: + self.item_pool.append(item) + elif item_info.type == "Goal": + locked_items.append(item) + elif item_info.type != "Protoss" or has_protoss: + self.item_pool.append(item) + self.cascade_removal_map: Dict[Item, List[Item]] = dict() + for item in self.item_pool + locked_items + existing_items: + if item.name in UPGRADABLE_ITEMS: + upgrades = get_item_upgrades(self.item_pool, item) + associated_items = [*upgrades, item] + self.cascade_removal_map[item] = associated_items + if get_option_value(world, player, "units_always_have_upgrades"): + for upgrade in upgrades: + self.cascade_removal_map[upgrade] = associated_items + + +def filter_items(world: MultiWorld, player: int, mission_req_table: Dict[str, MissionInfo], location_cache: List[Location], + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item]) -> List[Item]: + """ + Returns a semi-randomly pruned set of items based on number of available locations. + The returned inventory must be capable of logically accessing every location in the world. + """ + open_locations = [location for location in location_cache if location.item is None] + inventory_size = len(open_locations) + has_protoss = bool(PROTOSS_REGIONS.intersection(mission_req_table.keys())) + mission_requirements = [location.access_rule for location in location_cache] + valid_inventory = ValidInventory(world, player, item_pool, existing_items, locked_items, has_protoss) + + valid_items = valid_inventory.generate_reduced_inventory(inventory_size, mission_requirements) + return valid_items diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index 8219a982c9..b0a3a51e44 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -2,55 +2,47 @@ from typing import List, Set, Dict, Tuple, Optional, Callable from BaseClasses import MultiWorld, Region, Entrance, Location, RegionType from .Locations import LocationData from .Options import get_option_value -from .MissionTables import MissionInfo, vanilla_shuffle_order, vanilla_mission_req_table, \ - no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list +from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations +from .PoolFilter import filter_missions import random -def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location]): +def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location])\ + -> Tuple[Dict[str, MissionInfo], int, str]: locations_per_region = get_locations_per_region(locations) - regions = [ - create_region(world, player, locations_per_region, location_cache, "Menu"), - create_region(world, player, locations_per_region, location_cache, "Liberation Day"), - create_region(world, player, locations_per_region, location_cache, "The Outlaws"), - create_region(world, player, locations_per_region, location_cache, "Zero Hour"), - create_region(world, player, locations_per_region, location_cache, "Evacuation"), - create_region(world, player, locations_per_region, location_cache, "Outbreak"), - create_region(world, player, locations_per_region, location_cache, "Safe Haven"), - create_region(world, player, locations_per_region, location_cache, "Haven's Fall"), - create_region(world, player, locations_per_region, location_cache, "Smash and Grab"), - create_region(world, player, locations_per_region, location_cache, "The Dig"), - create_region(world, player, locations_per_region, location_cache, "The Moebius Factor"), - create_region(world, player, locations_per_region, location_cache, "Supernova"), - create_region(world, player, locations_per_region, location_cache, "Maw of the Void"), - create_region(world, player, locations_per_region, location_cache, "Devil's Playground"), - create_region(world, player, locations_per_region, location_cache, "Welcome to the Jungle"), - create_region(world, player, locations_per_region, location_cache, "Breakout"), - create_region(world, player, locations_per_region, location_cache, "Ghost of a Chance"), - create_region(world, player, locations_per_region, location_cache, "The Great Train Robbery"), - create_region(world, player, locations_per_region, location_cache, "Cutthroat"), - create_region(world, player, locations_per_region, location_cache, "Engine of Destruction"), - create_region(world, player, locations_per_region, location_cache, "Media Blitz"), - create_region(world, player, locations_per_region, location_cache, "Piercing the Shroud"), - create_region(world, player, locations_per_region, location_cache, "Whispers of Doom"), - create_region(world, player, locations_per_region, location_cache, "A Sinister Turn"), - create_region(world, player, locations_per_region, location_cache, "Echoes of the Future"), - create_region(world, player, locations_per_region, location_cache, "In Utter Darkness"), - create_region(world, player, locations_per_region, location_cache, "Gates of Hell"), - create_region(world, player, locations_per_region, location_cache, "Belly of the Beast"), - create_region(world, player, locations_per_region, location_cache, "Shatter the Sky"), - create_region(world, player, locations_per_region, location_cache, "All-In") - ] + mission_order_type = get_option_value(world, player, "mission_order") + mission_order = mission_orders[mission_order_type] + + mission_pools = filter_missions(world, player) + final_mission = mission_pools['all_in'][0] + + used_regions = [mission for mission_pool in mission_pools.values() for mission in mission_pool] + regions = [create_region(world, player, locations_per_region, location_cache, "Menu")] + for region_name in used_regions: + regions.append(create_region(world, player, locations_per_region, location_cache, region_name)) + # Changing the completion condition for alternate final missions into an event + if final_mission != 'All-In': + final_location = alt_final_mission_locations[final_mission] + # Final location should be near the end of the cache + for i in range(len(location_cache) - 1, -1, -1): + if location_cache[i].name == final_location: + location_cache[i].locked = True + location_cache[i].event = True + location_cache[i].address = None + break + else: + final_location = 'All-In: Victory' if __debug__: - throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) + if mission_order_type in (0, 1): + throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) world.regions += regions names: Dict[str, int] = {} - if get_option_value(world, player, "mission_order") == 0: + if mission_order_type == 0: connect(world, player, names, 'Menu', 'Liberation Day'), connect(world, player, names, 'Liberation Day', 'The Outlaws', lambda state: state.has("Beat Liberation Day", player)), @@ -119,32 +111,30 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData lambda state: state.has('Beat Gates of Hell', player) and ( state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player))) - return vanilla_mission_req_table + return vanilla_mission_req_table, 29, final_location - elif get_option_value(world, player, "mission_order") == 1: + else: missions = [] - no_build_pool = no_build_regions_list[:] - easy_pool = easy_regions_list[:] - medium_pool = medium_regions_list[:] - hard_pool = hard_regions_list[:] # Initial fill out of mission list and marking all-in mission - for mission in vanilla_shuffle_order: - if mission.type == "all_in": - missions.append("All-In") - elif get_option_value(world, player, "relegate_no_build") and mission.relegate: + for mission in mission_order: + if mission is None: + missions.append(None) + elif mission.type == "all_in": + missions.append(final_mission) + elif mission.relegate and not get_option_value(world, player, "shuffle_no_build"): missions.append("no_build") else: missions.append(mission.type) - # Place Protoss Missions if we are not using ShuffleProtoss - if get_option_value(world, player, "shuffle_protoss") == 0: + # Place Protoss Missions if we are not using ShuffleProtoss and are in Vanilla Shuffled + if get_option_value(world, player, "shuffle_protoss") == 0 and mission_order_type == 1: missions[22] = "A Sinister Turn" - medium_pool.remove("A Sinister Turn") + mission_pools['medium'].remove("A Sinister Turn") missions[23] = "Echoes of the Future" - medium_pool.remove("Echoes of the Future") + mission_pools['medium'].remove("Echoes of the Future") missions[24] = "In Utter Darkness" - hard_pool.remove("In Utter Darkness") + mission_pools['hard'].remove("In Utter Darkness") no_build_slots = [] easy_slots = [] @@ -153,6 +143,8 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData # Search through missions to find slots needed to fill for i in range(len(missions)): + if missions[i] is None: + continue if missions[i] == "no_build": no_build_slots.append(i) elif missions[i] == "easy": @@ -163,30 +155,30 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData hard_slots.append(i) # Add no_build missions to the pool and fill in no_build slots - missions_to_add = no_build_pool + missions_to_add = mission_pools['no_build'] for slot in no_build_slots: - filler = random.randint(0, len(missions_to_add)-1) + filler = world.random.randint(0, len(missions_to_add)-1) missions[slot] = missions_to_add.pop(filler) # Add easy missions into pool and fill in easy slots - missions_to_add = missions_to_add + easy_pool + missions_to_add = missions_to_add + mission_pools['easy'] for slot in easy_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add medium missions into pool and fill in medium slots - missions_to_add = missions_to_add + medium_pool + missions_to_add = missions_to_add + mission_pools['medium'] for slot in medium_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add hard missions into pool and fill in hard slots - missions_to_add = missions_to_add + hard_pool + missions_to_add = missions_to_add + mission_pools['hard'] for slot in hard_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) @@ -195,7 +187,7 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData mission_req_table = {} for i in range(len(missions)): connections = [] - for connection in vanilla_shuffle_order[i].connect_to: + for connection in mission_order[i].connect_to: if connection == -1: connect(world, player, names, "Menu", missions[i]) else: @@ -203,16 +195,17 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData (lambda name, missions_req: (lambda state: state.has(f"Beat {name}", player) and state._sc2wol_cleared_missions(world, player, missions_req))) - (missions[connection], vanilla_shuffle_order[i].number)) + (missions[connection], mission_order[i].number)) connections.append(connection + 1) mission_req_table.update({missions[i]: MissionInfo( - vanilla_mission_req_table[missions[i]].id, vanilla_mission_req_table[missions[i]].extra_locations, - connections, vanilla_shuffle_order[i].category, number=vanilla_shuffle_order[i].number, - completion_critical=vanilla_shuffle_order[i].completion_critical, - or_requirements=vanilla_shuffle_order[i].or_requirements)}) + vanilla_mission_req_table[missions[i]].id, connections, mission_order[i].category, + number=mission_order[i].number, + completion_critical=mission_order[i].completion_critical, + or_requirements=mission_order[i].or_requirements)}) - return mission_req_table + final_mission_id = vanilla_mission_req_table[final_mission].id + return mission_req_table, final_mission_id, final_mission + ': Victory' def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 6d056df808..70226e7afd 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -1,14 +1,16 @@ import typing -from typing import List, Set, Tuple +from typing import List, Set, Tuple, Dict from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification from worlds.AutoWorld import WebWorld, World from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups, get_full_item_list, \ - basic_unit + get_basic_units from .Locations import get_locations from .Regions import create_regions -from .Options import sc2wol_options, get_option_value +from .Options import sc2wol_options, get_option_value, get_option_set_value from .LogicMixin import SC2WoLLogic +from .PoolFilter import filter_missions, filter_items, get_item_upgrades +from .MissionTables import get_starting_mission_locations, MissionInfo class Starcraft2WoLWebWorld(WebWorld): @@ -42,6 +44,8 @@ class SC2WoLWorld(World): locked_locations: typing.List[str] location_cache: typing.List[Location] mission_req_table = {} + final_mission_id: int + victory_item: str required_client_version = 0, 3, 5 def __init__(self, world: MultiWorld, player: int): @@ -49,24 +53,21 @@ class SC2WoLWorld(World): self.location_cache = [] self.locked_locations = [] - def _create_items(self, name: str): - data = get_full_item_list()[name] - return [self.create_item(name) for _ in range(data.quantity)] - def create_item(self, name: str) -> Item: data = get_full_item_list()[name] return StarcraftWoLItem(name, data.classification, data.code, self.player) def create_regions(self): - self.mission_req_table = create_regions(self.world, self.player, get_locations(self.world, self.player), - self.location_cache) + self.mission_req_table, self.final_mission_id, self.victory_item = create_regions( + self.world, self.player, get_locations(self.world, self.player), self.location_cache + ) def generate_basic(self): excluded_items = get_excluded_items(self, self.world, self.player) - assign_starter_items(self.world, self.player, excluded_items, self.locked_locations) + starter_items = assign_starter_items(self.world, self.player, excluded_items, self.locked_locations) - pool = get_item_pool(self.world, self.player, excluded_items) + pool = get_item_pool(self.world, self.player, self.mission_req_table, starter_items, excluded_items, self.location_cache) fill_item_pool_with_dummy_items(self, self.world, self.player, self.locked_locations, self.location_cache, pool) @@ -74,8 +75,7 @@ class SC2WoLWorld(World): def set_rules(self): setup_events(self.world, self.player, self.locked_locations, self.location_cache) - - self.world.completion_condition[self.player] = lambda state: state.has('All-In: Victory', self.player) + self.world.completion_condition[self.player] = lambda state: state.has(self.victory_item, self.player) def get_filler_item_name(self) -> str: return self.world.random.choice(filler_items) @@ -91,6 +91,7 @@ class SC2WoLWorld(World): slot_req_table[mission] = self.mission_req_table[mission]._asdict() slot_data["mission_req"] = slot_req_table + slot_data["final_mission"] = self.final_mission_id return slot_data @@ -120,30 +121,37 @@ def get_excluded_items(self: SC2WoLWorld, world: MultiWorld, player: int) -> Set for item in world.precollected_items[player]: excluded_items.add(item.name) + excluded_items_option = getattr(world, 'excluded_items', []) + + excluded_items.update(excluded_items_option[player].value) + return excluded_items -def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str]): +def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str]) -> List[Item]: non_local_items = world.non_local_items[player].value + if get_option_value(world, player, "early_unit"): + local_basic_unit = tuple(item for item in get_basic_units(world, player) if item not in non_local_items) + if not local_basic_unit: + raise Exception("At least one basic unit must be local") - local_basic_unit = tuple(item for item in basic_unit if item not in non_local_items) - if not local_basic_unit: - raise Exception("At least one basic unit must be local") + # The first world should also be the starting world + first_mission = list(world.worlds[player].mission_req_table)[0] + starting_mission_locations = get_starting_mission_locations(world, player) + if first_mission in starting_mission_locations: + first_location = starting_mission_locations[first_mission] + elif first_mission == "In Utter Darkness": + first_location = first_mission + ": Defeat" + else: + first_location = first_mission + ": Victory" - # The first world should also be the starting world - first_location = list(world.worlds[player].mission_req_table)[0] - - if first_location == "In Utter Darkness": - first_location = first_location + ": Defeat" + return [assign_starter_item(world, player, excluded_items, locked_locations, first_location, local_basic_unit)] else: - first_location = first_location + ": Victory" - - assign_starter_item(world, player, excluded_items, locked_locations, first_location, - local_basic_unit) + return [] def assign_starter_item(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str], - location: str, item_list: Tuple[str, ...]): + location: str, item_list: Tuple[str, ...]) -> Item: item_name = world.random.choice(item_list) @@ -155,17 +163,40 @@ def assign_starter_item(world: MultiWorld, player: int, excluded_items: Set[str] locked_locations.append(location) + return item -def get_item_pool(world: MultiWorld, player: int, excluded_items: Set[str]) -> List[Item]: + +def get_item_pool(world: MultiWorld, player: int, mission_req_table: Dict[str, MissionInfo], + starter_items: List[str], excluded_items: Set[str], location_cache: List[Location]) -> List[Item]: pool: List[Item] = [] + # For the future: goal items like Artifact Shards go here + locked_items = [] + + # YAML items + yaml_locked_items = get_option_set_value(world, player, 'locked_items') + for name, data in item_table.items(): if name not in excluded_items: for _ in range(data.quantity): item = create_item_with_correct_settings(world, player, name) - pool.append(item) + if name in yaml_locked_items: + locked_items.append(item) + else: + pool.append(item) - return pool + existing_items = starter_items + [item for item in world.precollected_items[player]] + existing_names = [item.name for item in existing_items] + # Removing upgrades for excluded items + for item_name in excluded_items: + if item_name in existing_names: + continue + invalid_upgrades = get_item_upgrades(pool, item_name) + for invalid_upgrade in invalid_upgrades: + pool.remove(invalid_upgrade) + + filtered_pool = filter_items(world, player, mission_req_table, location_cache, pool, existing_items, locked_items) + return filtered_pool def fill_item_pool_with_dummy_items(self: SC2WoLWorld, world: MultiWorld, player: int, locked_locations: List[str], From 4b18920819a71a44b2a6455a2632ea8fb843028d Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Thu, 27 Oct 2022 03:00:24 -0400 Subject: [PATCH 08/17] Early Items option (#1086) * Early Items option * Early Items description update * Change Early Items to dict * Rewrite early items as extra fill steps * Move if statement up * Document early_items * Move early_items handling before fill_hook * Apply suggestions from code review Co-authored-by: Doug Hoskisson * Subnautica pre-fill moved to early_items * Subnautica early items fix * Remove unit test bug workaround * beauxq's pr Co-authored-by: Doug Hoskisson --- BaseClasses.py | 1 + Fill.py | 39 +++++++++++++++++++++ Options.py | 6 ++++ worlds/generic/docs/advanced_settings_en.md | 9 ++++- worlds/subnautica/Items.py | 2 +- worlds/subnautica/__init__.py | 19 ++-------- 6 files changed, 58 insertions(+), 18 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index ce2fc9e3c5..016c80ec83 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -48,6 +48,7 @@ class MultiWorld(): state: CollectionState accessibility: Dict[int, Options.Accessibility] + early_items: Dict[int, Options.EarlyItems] local_items: Dict[int, Options.LocalItems] non_local_items: Dict[int, Options.NonLocalItems] progression_balancing: Dict[int, Options.ProgressionBalancing] diff --git a/Fill.py b/Fill.py index cb9844b442..105b359134 100644 --- a/Fill.py +++ b/Fill.py @@ -258,6 +258,45 @@ def distribute_items_restrictive(world: MultiWorld) -> None: usefulitempool: typing.List[Item] = [] filleritempool: typing.List[Item] = [] + early_items_count: typing.Dict[typing.Tuple[str, int], int] = {} + for player in world.player_ids: + for item, count in world.early_items[player].value.items(): + early_items_count[(item, player)] = count + if early_items_count: + early_locations: typing.List[Location] = [] + early_priority_locations: typing.List[Location] = [] + for loc in reversed(fill_locations): + if loc.can_reach(world.state): + if loc.progress_type == LocationProgressType.PRIORITY: + early_priority_locations.append(loc) + else: + early_locations.append(loc) + fill_locations.remove(loc) + + early_prog_items: typing.List[Item] = [] + early_rest_items: typing.List[Item] = [] + for item in reversed(itempool): + if (item.name, item.player) in early_items_count: + if item.advancement: + early_prog_items.append(item) + else: + early_rest_items.append(item) + itempool.remove(item) + early_items_count[(item.name, item.player)] -= 1 + if early_items_count[(item.name, item.player)] == 0: + del early_items_count[(item.name, item.player)] + fill_restrictive(world, world.state, early_locations, early_rest_items, lock=True) + early_locations += early_priority_locations + fill_restrictive(world, world.state, early_locations, early_prog_items, lock=True) + unplaced_early_items = early_rest_items + early_prog_items + if unplaced_early_items: + logging.warning(f"Ran out of early locations for early items. Failed to place \ + {len(unplaced_early_items)} items early.") + itempool += unplaced_early_items + + fill_locations += early_locations + early_priority_locations + world.random.shuffle(fill_locations) + for item in itempool: if item.advancement: progitempool.append(item) diff --git a/Options.py b/Options.py index 536f388efb..ad87f5ebf8 100644 --- a/Options.py +++ b/Options.py @@ -883,6 +883,11 @@ class NonLocalItems(ItemSet): display_name = "Not Local Items" +class EarlyItems(ItemDict): + """Force the specified items to be in locations that are reachable from the start.""" + display_name = "Early Items" + + class StartInventory(ItemDict): """Start with these items.""" verify_item_name = True @@ -981,6 +986,7 @@ per_game_common_options = { **common_options, # can be overwritten per-game "local_items": LocalItems, "non_local_items": NonLocalItems, + "early_items": EarlyItems, "start_inventory": StartInventory, "start_hints": StartHints, "start_location_hints": StartLocationHints, diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index d19c9d5ee6..45f653e8bb 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -106,7 +106,7 @@ settings. If a game can be rolled it **must** have a settings section even if it Some options in Archipelago can be used by every game but must still be placed within the relevant game's section. -Currently, these options are `start_inventory`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints` +Currently, these options are `start_inventory`, `early_items`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints` , `exclude_locations`, and various plando options. See the plando guide for more info on plando options. Plando @@ -115,6 +115,8 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) * `start_inventory` will give any items defined here to you at the beginning of your game. The format for this must be the name as it appears in the game files and the amount you would like to start with. For example `Rupees(5): 6` which will give you 30 rupees. +* `early_items` is formatted in the same way as `start_inventory` and will force the number of each item specified to be +forced into locations that are reachable from the start, before obtaining any items. * `start_hints` gives you free server hints for the defined item/s at the beginning of the game allowing you to hint for the location without using any hint points. * `local_items` will force any items you want to be in your world instead of being in another world. @@ -172,6 +174,8 @@ A Link to the Past: - Quake non_local_items: - Moon Pearl + early_items: + Flute: 1 start_location_hints: - Spike Cave priority_locations: @@ -235,6 +239,9 @@ Timespinner: * `local_items` forces the `Bombos`, `Ether`, and `Quake` medallions to all be placed within our own world, meaning we have to find it ourselves. * `non_local_items` forces the `Moon Pearl` to be placed in someone else's world, meaning we won't be able to find it. +* `early_items` forces the `Flute` to be placed in a location that is available from the beginning of the game ("Sphere +1"). Since it is not specified in `local_items` or `non_local_items`, it can be placed one of these locations in any +world. * `start_location_hints` gives us a starting hint for the `Spike Cave` location available at the beginning of the multiworld that can be used for no cost. * `priority_locations` forces a progression item to be placed on the `Link's House` location. diff --git a/worlds/subnautica/Items.py b/worlds/subnautica/Items.py index 4201cf3910..4a9eeabdfd 100644 --- a/worlds/subnautica/Items.py +++ b/worlds/subnautica/Items.py @@ -175,7 +175,7 @@ item_table: Dict[int, ItemDict] = { 'name': 'Thermal Plant Fragment', 'tech_type': 'ThermalPlantFragment'}, 35041: {'classification': ItemClassification.progression, - 'count': 2, + 'count': 4, 'name': 'Seaglide Fragment', 'tech_type': 'SeaglideFragment'}, 35042: {'classification': ItemClassification.progression, diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index 830bc831ef..bd86dc5ce7 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -44,14 +44,12 @@ class SubnauticaWorld(World): data_version = 7 required_client_version = (0, 3, 5) - prefill_items: List[Item] creatures_to_scan: List[str] def generate_early(self) -> None: - self.prefill_items = [ - self.create_item("Seaglide Fragment"), - self.create_item("Seaglide Fragment") - ] + if "Seaglide Fragment" not in self.world.early_items[self.player]: + self.world.early_items[self.player].value["Seaglide Fragment"] = 2 + scan_option: Options.AggressiveScanLogic = self.world.creature_scan_logic[self.player] creature_pool = scan_option.get_pool() @@ -149,17 +147,6 @@ class SubnauticaWorld(World): ret.exits.append(Entrance(self.player, region_exit, ret)) return ret - def get_pre_fill_items(self) -> List[Item]: - return self.prefill_items - - def pre_fill(self) -> None: - reachable = [location for location in self.world.get_reachable_locations(player=self.player) - if not location.item] - self.world.random.shuffle(reachable) - items = self.prefill_items.copy() - for item in items: - reachable.pop().place_locked_item(item) - class SubnauticaLocation(Location): game: str = "Subnautica" From b57ca33c31a8af43f561a8625d8aae59d042bca3 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Thu, 27 Oct 2022 09:18:25 +0200 Subject: [PATCH 09/17] Logging: more digits for IDs and counts (#1141) * Logging: we now need 9 digits for IDs * Logging: we now need {dynamic} digits for IDs * Logging: we now need {dynamic} digits for counts --- Main.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/Main.py b/Main.py index 63f5b8a818..38100bd050 100644 --- a/Main.py +++ b/Main.py @@ -80,15 +80,30 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info("Found World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) - numlength = 8 + + max_item = 0 + max_location = 0 + for cls in AutoWorld.AutoWorldRegister.world_types.values(): + if cls.item_id_to_name: + max_item = max(max_item, max(cls.item_id_to_name)) + max_location = max(max_location, max(cls.location_id_to_name)) + + item_digits = len(str(max_item)) + location_digits = len(str(max_location)) + item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) + location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) + del max_item, max_location + for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): if not cls.hidden and len(cls.item_names) > 0: - logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} " - f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - " - f"{max(cls.item_id_to_name):{numlength}}) | " - f"{len(cls.location_names):3} " - f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - " - f"{max(cls.location_id_to_name):{numlength}})") + logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " + f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " + f"{max(cls.item_id_to_name):{item_digits}}) | " + f"{len(cls.location_names):{location_count}} " + f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - " + f"{max(cls.location_id_to_name):{location_digits}})") + + del item_digits, location_digits, item_count, location_count AutoWorld.call_stage(world, "assert_generate") From 6134578c60341f4603f371c6cbb1ea6dad2135ed Mon Sep 17 00:00:00 2001 From: toasterparty Date: Thu, 27 Oct 2022 02:19:48 -0700 Subject: [PATCH 10/17] Overcooked! 2: slightly relax 3-star logic (#1144) --- worlds/overcooked2/Logic.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/worlds/overcooked2/Logic.py b/worlds/overcooked2/Logic.py index 6fb1a50a41..a4f1f0fb86 100644 --- a/worlds/overcooked2/Logic.py +++ b/worlds/overcooked2/Logic.py @@ -291,6 +291,11 @@ level_logic = { "Progressive Throw/Catch", ], { # Additive + ("Sharp Knife", 1.0), + ("Dish Scrubber", 1.0), + ("Clean Dishes", 0.5), + ("Guest Patience", 0.25), + ("Burn Leniency", 0.25), }, ) ), From aeb78eaa1000b906f97da7c0e605f3f83b749fca Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Thu, 27 Oct 2022 02:30:22 -0700 Subject: [PATCH 11/17] Zillion: map tracker in client (#1136) * Option RangeWithSpecialMax * amendment to typing in web options * compare string with number * lots of work on zillion * fix zillion fill logic * fix a few more issues in zillion fill logic * can make zillion patch and use it * put multi items in zillion rom * work on ZillionClient * logging and auth in client * work on sending and receiving items * implement item_handling flag * fix locations ids to NuktiServer package * use rewrite of zri * cache logic rule data for performance * use new id maps * fix some problems with the big recent merge * ZillionClient: use new context manager for Memory class * fix ItemClassification for Zillion items and some debug statements for asserts, documentation on running scripts for manual testing type correction in CommonContext * fix some issues in client, start on docs, put rescue and item ram addresses in slot data * use new location name system fix item locations getting out of sync in progression balancing * zillion client can read slot name from game * zillion: new item names * remove extra unneeded import * newer options (room gen and starting cards) * update comment in zillion patch * zillion non static regions * change some logging, update some comments * allow ZillionClient to exit in certain situations * todo note to fix options doc strings * don't force auto forfeit * rework validation of floppy requirement and item counts and fix race condition in generate_output * reorganize Zillion component structure with System class * documentation updates for Zillion * attempt inno_setup.iss * remove todo comment for something done * update comment * rework item count zillion options and some small cleanups * fix location check count * data package version 1 * Zillion can pass unit tests without rom * fix freeze if closing ZillionClient while it's waiting for server login * specify commit hash for zilliandomizer package * some changes to options validation * Zillion doors saved on multiworld server * add missing function in inno_setup and name of vanilla continues in options * rework zillion sync task and context * Apply documentation suggestions from SoldierofOrder Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com> * update zillion package * workaround for asyncio udp bug There is a bug in Python in Windows https://github.com/python/cpython/issues/91227 that makes it so if I look for RetroArch before it's ready, it breaks the asyncio udp transport system. As a workaround, we don't look for RetroArch until the user asks for it with /sms * a few of the smaller suggestions from review * logic only looks at my locations instead of all the multiworld locations * some adjustments from pull request discussion and some unit tests * patch webhost changes from pull request discussion * zillion logic tests * better vblr test * test interaction of character rescue items with logic * move unit tests to new worlds folder * comment improvements * fix minor logic issue and add memory read timeout * capitalization in option display names Opa-Opa is a proper noun * client toggle side panel with /map * displays map * fix map transparency * fix broken launcher * better way to specify grid container * start kivy typing * have a map that updates with item checks but it breaks other parts of the UI * fix layout bug * aspect ratio of image and some type checking details * Fix loading of map for compiled builds Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com> Co-authored-by: Doug Hoskisson Co-authored-by: CaitSith2 --- ZillionClient.py | 111 +++++++++++++++++- kvui.py | 11 +- typings/kivy/__init__.pyi | 0 typings/kivy/app.pyi | 2 + typings/kivy/core/__init__.pyi | 0 typings/kivy/core/text.pyi | 7 ++ typings/kivy/graphics.pyi | 40 +++++++ typings/kivy/uix/__init__.pyi | 0 typings/kivy/uix/layout.pyi | 8 ++ typings/kivy/uix/tabbedpanel.pyi | 12 ++ typings/kivy/uix/widget.pyi | 31 +++++ worlds/sa2b/Names/__init__.py | 0 worlds/zillion/config.py | 3 + .../empty-zillion-map-row-col-labels-281.png | Bin 0 -> 29903 bytes 14 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 typings/kivy/__init__.pyi create mode 100644 typings/kivy/app.pyi create mode 100644 typings/kivy/core/__init__.pyi create mode 100644 typings/kivy/core/text.pyi create mode 100644 typings/kivy/graphics.pyi create mode 100644 typings/kivy/uix/__init__.pyi create mode 100644 typings/kivy/uix/layout.pyi create mode 100644 typings/kivy/uix/tabbedpanel.pyi create mode 100644 typings/kivy/uix/widget.pyi create mode 100644 worlds/sa2b/Names/__init__.py create mode 100644 worlds/zillion/empty-zillion-map-row-col-labels-281.png diff --git a/ZillionClient.py b/ZillionClient.py index 8ad1065057..e2ce697c8a 100644 --- a/ZillionClient.py +++ b/ZillionClient.py @@ -1,7 +1,7 @@ import asyncio import base64 import platform -from typing import Any, Coroutine, Dict, Optional, Tuple, Type, cast +from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast # CommonClient import first to trigger ModuleUpdater from CommonClient import CommonContext, server_loop, gui_enabled, \ @@ -18,7 +18,7 @@ from zilliandomizer.options import Chars from zilliandomizer.patch import RescueInfo from worlds.zillion.id_maps import make_id_to_others -from worlds.zillion.config import base_id +from worlds.zillion.config import base_id, zillion_map class ZillionCommandProcessor(ClientCommandProcessor): @@ -29,6 +29,18 @@ class ZillionCommandProcessor(ClientCommandProcessor): logger.info("ready to look for game") self.ctx.look_for_retroarch.set() + def _cmd_map(self) -> None: + """ Toggle view of the map tracker. """ + self.ctx.ui_toggle_map() + + +class ToggleCallback(Protocol): + def __call__(self) -> None: ... + + +class SetRoomCallback(Protocol): + def __call__(self, rooms: List[List[int]]) -> None: ... + class ZillionContext(CommonContext): game = "Zillion" @@ -61,6 +73,10 @@ class ZillionContext(CommonContext): As a workaround, we don't look for RetroArch until this event is set. """ + ui_toggle_map: ToggleCallback + ui_set_rooms: SetRoomCallback + """ parameter is y 16 x 8 numbers to show in each room """ + def __init__(self, server_address: str, password: str) -> None: @@ -69,6 +85,8 @@ class ZillionContext(CommonContext): self.to_game = asyncio.Queue() self.got_room_info = asyncio.Event() self.got_slot_data = asyncio.Event() + self.ui_toggle_map = lambda: None + self.ui_set_rooms = lambda rooms: None self.look_for_retroarch = asyncio.Event() if platform.system() != "Windows": @@ -115,6 +133,10 @@ class ZillionContext(CommonContext): # override def run_gui(self) -> None: from kvui import GameManager + from kivy.core.text import Label as CoreLabel + from kivy.graphics import Ellipse, Color, Rectangle + from kivy.uix.layout import Layout + from kivy.uix.widget import Widget class ZillionManager(GameManager): logging_pairs = [ @@ -122,12 +144,76 @@ class ZillionContext(CommonContext): ] base_title = "Archipelago Zillion Client" + class MapPanel(Widget): + MAP_WIDTH: ClassVar[int] = 281 + + _number_textures: List[Any] = [] + rooms: List[List[int]] = [] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.rooms = [[0 for _ in range(8)] for _ in range(16)] + + self._make_numbers() + self.update_map() + + self.bind(pos=self.update_map) + # self.bind(size=self.update_bg) + + def _make_numbers(self) -> None: + self._number_textures = [] + for n in range(10): + label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1)) + label.refresh() + self._number_textures.append(label.texture) + + def update_map(self, *args: Any) -> None: + self.canvas.clear() + + with self.canvas: + Color(1, 1, 1, 1) + Rectangle(source=zillion_map, + pos=self.pos, + size=(ZillionManager.MapPanel.MAP_WIDTH, + int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image + for y in range(16): + for x in range(8): + num = self.rooms[15 - y][x] + if num > 0: + Color(0, 0, 0, 0.4) + pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24] + Ellipse(size=[22, 22], pos=pos) + Color(1, 1, 1, 1) + pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24] + num_texture = self._number_textures[num] + Rectangle(texture=num_texture, size=num_texture.size, pos=pos) + + def build(self) -> Layout: + container = super().build() + self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0) + self.main_area_container.add_widget(self.map_widget) + return container + + def toggle_map_width(self) -> None: + if self.map_widget.width == 0: + self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH + else: + self.map_widget.width = 0 + self.container.do_layout() + + def set_rooms(self, rooms: List[List[int]]) -> None: + self.map_widget.rooms = rooms + self.map_widget.update_map() + self.ui = ZillionManager(self) - run_co: Coroutine[Any, Any, None] = self.ui.async_run() # type: ignore - # kivy types missing + self.ui_toggle_map = lambda: self.ui.toggle_map_width() + self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms) + run_co: Coroutine[Any, Any, None] = self.ui.async_run() self.ui_task = asyncio.create_task(run_co, name="UI") def on_package(self, cmd: str, args: Dict[str, Any]) -> None: + self.room_item_numbers_to_ui() if cmd == "Connected": logger.info("logged in to Archipelago server") if "slot_data" not in args: @@ -192,6 +278,21 @@ class ZillionContext(CommonContext): self.seed_name = args["seed_name"] self.got_room_info.set() + def room_item_numbers_to_ui(self) -> None: + rooms = [[0 for _ in range(8)] for _ in range(16)] + for loc_id in self.missing_locations: + loc_id_small = loc_id - base_id + loc_name = id_to_loc[loc_id_small] + y = ord(loc_name[0]) - 65 + x = ord(loc_name[2]) - 49 + if y == 9 and x == 5: + # don't show main computer in numbers + continue + assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}" + rooms[y][x] += 1 + # TODO: also add locations with locals lost from loading save state or reset + self.ui_set_rooms(rooms) + def process_from_game_queue(self) -> None: if self.from_game.qsize(): event_from_game = self.from_game.get_nowait() @@ -251,7 +352,7 @@ def name_seed_from_ram(data: bytes) -> Tuple[str, str]: return "", "xxx" null_index = data.find(b'\x00') if null_index == -1: - logger.warning(f"invalid game id in rom {data}") + logger.warning(f"invalid game id in rom {repr(data)}") null_index = len(data) name = data[:null_index].decode() null_index_2 = data.find(b'\x00', null_index + 1) diff --git a/kvui.py b/kvui.py index 3c1161f99b..3820864538 100644 --- a/kvui.py +++ b/kvui.py @@ -28,6 +28,7 @@ from kivy.factory import Factory from kivy.properties import BooleanProperty, ObjectProperty from kivy.uix.button import Button from kivy.uix.gridlayout import GridLayout +from kivy.uix.layout import Layout from kivy.uix.textinput import TextInput from kivy.uix.recycleview import RecycleView from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem @@ -299,6 +300,9 @@ class GameManager(App): base_title: str = "Archipelago Client" last_autofillable_command: str + main_area_container: GridLayout + """ subclasses can add more columns beside the tabs """ + def __init__(self, ctx: context_type): self.title = self.base_title self.ctx = ctx @@ -325,7 +329,7 @@ class GameManager(App): super(GameManager, self).__init__() - def build(self): + def build(self) -> Layout: self.container = ContainerLayout() self.grid = MainLayout() @@ -358,7 +362,10 @@ class GameManager(App): self.log_panels[display_name] = panel.content = UILog(bridge_logger) self.tabs.add_widget(panel) - self.grid.add_widget(self.tabs) + self.main_area_container = GridLayout(size_hint_y=1, rows=1) + self.main_area_container.add_widget(self.tabs) + + self.grid.add_widget(self.main_area_container) if len(self.logging_pairs) == 1: # Hide Tab selection if only one tab diff --git a/typings/kivy/__init__.pyi b/typings/kivy/__init__.pyi new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typings/kivy/app.pyi b/typings/kivy/app.pyi new file mode 100644 index 0000000000..bb41bf6b2b --- /dev/null +++ b/typings/kivy/app.pyi @@ -0,0 +1,2 @@ +class App: + async def async_run(self) -> None: ... diff --git a/typings/kivy/core/__init__.pyi b/typings/kivy/core/__init__.pyi new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typings/kivy/core/text.pyi b/typings/kivy/core/text.pyi new file mode 100644 index 0000000000..7b13ad3424 --- /dev/null +++ b/typings/kivy/core/text.pyi @@ -0,0 +1,7 @@ +from typing import Tuple +from ..graphics import FillType_Shape +from ..uix.widget import Widget + + +class Label(FillType_Shape, Widget): + def __init__(self, *, text: str, font_size: int, color: Tuple[float, float, float, float]) -> None: ... diff --git a/typings/kivy/graphics.pyi b/typings/kivy/graphics.pyi new file mode 100644 index 0000000000..1950910661 --- /dev/null +++ b/typings/kivy/graphics.pyi @@ -0,0 +1,40 @@ +""" FillType_* is not a real kivy type - just something to fill unknown typing. """ + +from typing import Sequence + +FillType_Vec = Sequence[int] + + +class FillType_Drawable: + def __init__(self, *, pos: FillType_Vec = ..., size: FillType_Vec = ...) -> None: ... + + +class FillType_Texture(FillType_Drawable): + pass + + +class FillType_Shape(FillType_Drawable): + texture: FillType_Texture + + def __init__(self, + *, + texture: FillType_Texture = ..., + pos: FillType_Vec = ..., + size: FillType_Vec = ...) -> None: ... + + +class Ellipse(FillType_Shape): + pass + + +class Color: + def __init__(self, r: float, g: float, b: float, a: float) -> None: ... + + +class Rectangle(FillType_Shape): + def __init__(self, + *, + source: str = ..., + texture: FillType_Texture = ..., + pos: FillType_Vec = ..., + size: FillType_Vec = ...) -> None: ... diff --git a/typings/kivy/uix/__init__.pyi b/typings/kivy/uix/__init__.pyi new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typings/kivy/uix/layout.pyi b/typings/kivy/uix/layout.pyi new file mode 100644 index 0000000000..2a418a1d8b --- /dev/null +++ b/typings/kivy/uix/layout.pyi @@ -0,0 +1,8 @@ +from typing import Any +from .widget import Widget + + +class Layout(Widget): + def add_widget(self, widget: Widget) -> None: ... + + def do_layout(self, *largs: Any, **kwargs: Any) -> None: ... diff --git a/typings/kivy/uix/tabbedpanel.pyi b/typings/kivy/uix/tabbedpanel.pyi new file mode 100644 index 0000000000..9183b4c8b3 --- /dev/null +++ b/typings/kivy/uix/tabbedpanel.pyi @@ -0,0 +1,12 @@ +from .layout import Layout +from .widget import Widget + + +class TabbedPanel(Layout): + pass + + +class TabbedPanelItem(Widget): + content: Widget + + def __init__(self, *, text: str = ...) -> None: ... diff --git a/typings/kivy/uix/widget.pyi b/typings/kivy/uix/widget.pyi new file mode 100644 index 0000000000..54e3b781ea --- /dev/null +++ b/typings/kivy/uix/widget.pyi @@ -0,0 +1,31 @@ +""" FillType_* is not a real kivy type - just something to fill unknown typing. """ + +from typing import Any, Optional, Protocol +from ..graphics import FillType_Drawable, FillType_Vec + + +class FillType_BindCallback(Protocol): + def __call__(self, *args: Any) -> None: ... + + +class FillType_Canvas: + def add(self, drawable: FillType_Drawable) -> None: ... + + def clear(self) -> None: ... + + def __enter__(self) -> None: ... + + def __exit__(self, *args: Any) -> None: ... + + +class Widget: + canvas: FillType_Canvas + width: int + pos: FillType_Vec + + def bind(self, + *, + pos: Optional[FillType_BindCallback] = ..., + size: Optional[FillType_BindCallback] = ...) -> None: ... + + def refresh(self) -> None: ... diff --git a/worlds/sa2b/Names/__init__.py b/worlds/sa2b/Names/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/zillion/config.py b/worlds/zillion/config.py index e08c4f4278..ca02f9a99f 100644 --- a/worlds/zillion/config.py +++ b/worlds/zillion/config.py @@ -1 +1,4 @@ +import os + base_id = 8675309 +zillion_map = os.path.join(os.path.dirname(__file__), "empty-zillion-map-row-col-labels-281.png") diff --git a/worlds/zillion/empty-zillion-map-row-col-labels-281.png b/worlds/zillion/empty-zillion-map-row-col-labels-281.png new file mode 100644 index 0000000000000000000000000000000000000000..3084301f7b0274e9351370168509d59924964e66 GIT binary patch literal 29903 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 wF8>9n Date: Fri, 28 Oct 2022 00:07:57 +0200 Subject: [PATCH 12/17] Factorio: Add optional filtering for item sends displayed in-game (#1142) * Factorio: Added feature to filter item sends displayed in-game. * Factorio: Document item send filter feature. * Factorio: Fix item send filter for item links. * (Removed superfluous type annotations.) * CommonClient: Added is_uninteresting_item_send helper. --- CommonClient.py | 6 +++++ FactorioClient.py | 27 +++++++++++++++++-- Utils.py | 1 + host.yaml | 2 ++ worlds/factorio/data/mod_template/control.lua | 4 +++ worlds/factorio/docs/setup_en.md | 12 +++++++++ 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index c713373592..4fc82b5e67 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -312,6 +312,12 @@ class CommonContext: return self.slot in self.slot_info[slot].group_members return False + def is_uninteresting_item_send(self, print_json_packet: dict) -> bool: + """Helper function for filtering out ItemSend prints that do not concern the local player.""" + return print_json_packet.get("type", "") == "ItemSend" \ + and not self.slot_concerns_self(print_json_packet["receiving"]) \ + and not self.slot_concerns_self(print_json_packet["item"].player) + def on_print(self, args: dict): logger.info(args["text"]) diff --git a/FactorioClient.py b/FactorioClient.py index 1efca05d3c..8ab9799b7c 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -4,6 +4,7 @@ import logging import json import string import copy +import re import subprocess import time import random @@ -46,6 +47,10 @@ class FactorioCommandProcessor(ClientCommandProcessor): """Manually trigger a resync.""" self.ctx.awaiting_bridge = True + def _cmd_toggle_send_filter(self): + """Toggle filtering of item sends that get displayed in-game to only those that involve you.""" + self.ctx.toggle_filter_item_sends() + class FactorioContext(CommonContext): command_processor = FactorioCommandProcessor @@ -65,6 +70,7 @@ class FactorioContext(CommonContext): self.factorio_json_text_parser = FactorioJSONtoTextParser(self) self.energy_link_increment = 0 self.last_deplete = 0 + self.filter_item_sends: bool = False async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -85,8 +91,9 @@ class FactorioContext(CommonContext): def on_print_json(self, args: dict): if self.rcon_client: - text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) - self.print_to_game(text) + if not self.filter_item_sends or not self.is_uninteresting_item_send(args): + text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) + self.print_to_game(text) super(FactorioContext, self).on_print_json(args) @property @@ -123,6 +130,15 @@ class FactorioContext(CommonContext): f"{Utils.format_SI_prefix(args['value'])}J remaining.") self.rcon_client.send_command(f"/ap-energylink {gained}") + def toggle_filter_item_sends(self) -> None: + self.filter_item_sends = not self.filter_item_sends + if self.filter_item_sends: + announcement = "Item sends are now filtered." + else: + announcement = "Item sends are no longer filtered." + logger.info(announcement) + self.print_to_game(announcement) + def run_gui(self): from kvui import GameManager @@ -262,6 +278,9 @@ async def factorio_server_watcher(ctx: FactorioContext): if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: ctx.awaiting_bridge = True factorio_server_logger.debug(msg) + elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter", msg): + factorio_server_logger.debug(msg) + ctx.toggle_filter_item_sends() else: factorio_server_logger.info(msg) if ctx.rcon_client: @@ -363,6 +382,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: async def main(args): ctx = FactorioContext(args.connect, args.password) + ctx.filter_item_sends = initial_filter_item_sends ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") if gui_enabled: @@ -415,6 +435,9 @@ if __name__ == '__main__': server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None) if server_settings: server_settings = os.path.abspath(server_settings) + if not isinstance(options["factorio_options"]["filter_item_sends"], bool): + logging.warning(f"Warning: Option filter_item_sends should be a bool.") + initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"]) if not os.path.exists(os.path.dirname(executable)): raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") diff --git a/Utils.py b/Utils.py index 64a028fc33..b2e98358bc 100644 --- a/Utils.py +++ b/Utils.py @@ -231,6 +231,7 @@ def get_default_options() -> OptionsType: }, "factorio_options": { "executable": os.path.join("factorio", "bin", "x64", "factorio"), + "filter_item_sends": False, }, "sni_options": { "sni": "SNI", diff --git a/host.yaml b/host.yaml index 2c5a8e3e1d..0bdd95356e 100644 --- a/host.yaml +++ b/host.yaml @@ -99,6 +99,8 @@ factorio_options: executable: "factorio/bin/x64/factorio" # by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used. # server_settings: "factorio\\data\\server-settings.json" + # Whether to filter item send messages displayed in-game to only those that involve you. + filter_item_sends: false minecraft_options: forge_directory: "Minecraft Forge server" max_heap_size: "2G" diff --git a/worlds/factorio/data/mod_template/control.lua b/worlds/factorio/data/mod_template/control.lua index 51cd21e4da..86e83b9f4d 100644 --- a/worlds/factorio/data/mod_template/control.lua +++ b/worlds/factorio/data/mod_template/control.lua @@ -596,5 +596,9 @@ commands.add_command("ap-energylink", "Used by the Archipelago client to manage global.forcedata[force].energy = global.forcedata[force].energy + change end) +commands.add_command("toggle-ap-send-filter", "Toggle filtering of item sends that get displayed in-game to only those that involve you.", function(call) + log("Player command toggle-ap-send-filter") -- notifies client +end) + -- data progressive_technologies = {{ dict_to_lua(progressive_technology_table) }} diff --git a/worlds/factorio/docs/setup_en.md b/worlds/factorio/docs/setup_en.md index 560a37d1e3..8b24da13d5 100644 --- a/worlds/factorio/docs/setup_en.md +++ b/worlds/factorio/docs/setup_en.md @@ -141,6 +141,18 @@ you can also issue the `!help` command to learn about additional commands like ` 4. Provide your IP address to anyone you want to join your game, and have them follow the steps for "Connecting to Someone Else's Factorio Game" above. +## Other Settings + +- By default, all item sends are displayed in-game. In larger async seeds this may become overly spammy. + To hide all item sends that are not to or from your factory, do one of the following: + - Type `/toggle-ap-send-filter` in-game + - Type `/toggle_send_filter` in the Archipelago Client + - In your `host.yaml` set + ``` + factorio_options: + filter_item_sends: true + ``` + ## Troubleshooting In case any problems should occur, the Archipelago Client will create a file `FactorioClient.txt` in the `/logs`. The From cfff12d8d7a7ae36cef355d0f93449dd2d8b1027 Mon Sep 17 00:00:00 2001 From: recklesscoder <57289227+recklesscoder@users.noreply.github.com> Date: Fri, 28 Oct 2022 00:45:26 +0200 Subject: [PATCH 13/17] Factorio: Added ability to chat from within the game. (#1068) * Factorio: Added ability to chat from within the game. This also allows using commands such as !hint from within the game. * Factorio: Only prepend player names to chat in multiplayer. * Factorio: Mirror chat sent from the FactorioClient UI to the Factorio server. * Factorio: Remove local coordinates from outgoing chat. * Factorio: Added setting to disable bridging chat out. Added client command to toggle this setting at run-time. * Factorio: Added in-game command to toggle chat bridging setting at run-time. * . * Factorio: Document toggle for chat bridging feature. * (Removed superfluous type annotations.) * (Removed hard to read regex.) * Docs/Factorio: Fix display of multiline code snippets. --- FactorioClient.py | 53 ++++++++++++++++++- Utils.py | 1 + host.yaml | 2 + worlds/factorio/data/mod_template/control.lua | 6 +++ worlds/factorio/docs/setup_en.md | 27 ++++++---- 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/FactorioClient.py b/FactorioClient.py index 8ab9799b7c..12ec22916b 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -8,6 +8,7 @@ import re import subprocess import time import random +import typing import ModuleUpdate ModuleUpdate.update() @@ -51,6 +52,9 @@ class FactorioCommandProcessor(ClientCommandProcessor): """Toggle filtering of item sends that get displayed in-game to only those that involve you.""" self.ctx.toggle_filter_item_sends() + def _cmd_toggle_chat(self): + """Toggle sending of chat messages from players on the Factorio server to Archipelago.""" + self.ctx.toggle_bridge_chat_out() class FactorioContext(CommonContext): command_processor = FactorioCommandProcessor @@ -71,6 +75,8 @@ class FactorioContext(CommonContext): self.energy_link_increment = 0 self.last_deplete = 0 self.filter_item_sends: bool = False + self.multiplayer: bool = False # whether multiple different players have connected + self.bridge_chat_out: bool = True async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -87,13 +93,15 @@ class FactorioContext(CommonContext): def on_print(self, args: dict): super(FactorioContext, self).on_print(args) if self.rcon_client: - self.print_to_game(args['text']) + if not args['text'].startswith(self.player_names[self.slot] + ":"): + self.print_to_game(args['text']) def on_print_json(self, args: dict): if self.rcon_client: if not self.filter_item_sends or not self.is_uninteresting_item_send(args): text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) - self.print_to_game(text) + if not text.startswith(self.player_names[self.slot] + ":"): + self.print_to_game(text) super(FactorioContext, self).on_print_json(args) @property @@ -130,6 +138,27 @@ class FactorioContext(CommonContext): f"{Utils.format_SI_prefix(args['value'])}J remaining.") self.rcon_client.send_command(f"/ap-energylink {gained}") + def on_user_say(self, text: str) -> typing.Optional[str]: + # Mirror chat sent from the UI to the Factorio server. + self.print_to_game(f"{self.player_names[self.slot]}: {text}") + return text + + async def chat_from_factorio(self, user: str, message: str) -> None: + if not self.bridge_chat_out: + return + + # Pass through commands + if message.startswith("!"): + await self.send_msgs([{"cmd": "Say", "text": message}]) + return + + # Omit messages that contain local coordinates + if "[gps=" in message: + return + + prefix = f"({user}) " if self.multiplayer else "" + await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}]) + def toggle_filter_item_sends(self) -> None: self.filter_item_sends = not self.filter_item_sends if self.filter_item_sends: @@ -139,6 +168,15 @@ class FactorioContext(CommonContext): logger.info(announcement) self.print_to_game(announcement) + def toggle_bridge_chat_out(self) -> None: + self.bridge_chat_out = not self.bridge_chat_out + if self.bridge_chat_out: + announcement = "Chat is now bridged to Archipelago." + else: + announcement = "Chat is no longer bridged to Archipelago." + logger.info(announcement) + self.print_to_game(announcement) + def run_gui(self): from kvui import GameManager @@ -178,6 +216,7 @@ async def game_watcher(ctx: FactorioContext): research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} victory = data["victory"] await ctx.update_death_link(data["death_link"]) + ctx.multiplayer = data.get("multiplayer", False) if not ctx.finished_game and victory: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) @@ -281,8 +320,14 @@ async def factorio_server_watcher(ctx: FactorioContext): elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter", msg): factorio_server_logger.debug(msg) ctx.toggle_filter_item_sends() + elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg): + factorio_server_logger.debug(msg) + ctx.toggle_bridge_chat_out() else: factorio_server_logger.info(msg) + match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg) + if match: + await ctx.chat_from_factorio(match.group(1), match.group(2)) if ctx.rcon_client: commands = {} while ctx.send_index < len(ctx.items_received): @@ -383,6 +428,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: async def main(args): ctx = FactorioContext(args.connect, args.password) ctx.filter_item_sends = initial_filter_item_sends + ctx.bridge_chat_out = initial_bridge_chat_out ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") if gui_enabled: @@ -438,6 +484,9 @@ if __name__ == '__main__': if not isinstance(options["factorio_options"]["filter_item_sends"], bool): logging.warning(f"Warning: Option filter_item_sends should be a bool.") initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"]) + if not isinstance(options["factorio_options"]["bridge_chat_out"], bool): + logging.warning(f"Warning: Option bridge_chat_out should be a bool.") + initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"]) if not os.path.exists(os.path.dirname(executable)): raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") diff --git a/Utils.py b/Utils.py index b2e98358bc..e0c86ddb39 100644 --- a/Utils.py +++ b/Utils.py @@ -232,6 +232,7 @@ def get_default_options() -> OptionsType: "factorio_options": { "executable": os.path.join("factorio", "bin", "x64", "factorio"), "filter_item_sends": False, + "bridge_chat_out": True, }, "sni_options": { "sni": "SNI", diff --git a/host.yaml b/host.yaml index 0bdd95356e..4e94a9a30c 100644 --- a/host.yaml +++ b/host.yaml @@ -101,6 +101,8 @@ factorio_options: # server_settings: "factorio\\data\\server-settings.json" # Whether to filter item send messages displayed in-game to only those that involve you. filter_item_sends: false + # Whether to send chat messages from players on the Factorio server to Archipelago. + bridge_chat_out: true minecraft_options: forge_directory: "Minecraft Forge server" max_heap_size: "2G" diff --git a/worlds/factorio/data/mod_template/control.lua b/worlds/factorio/data/mod_template/control.lua index 86e83b9f4d..98c9ca621a 100644 --- a/worlds/factorio/data/mod_template/control.lua +++ b/worlds/factorio/data/mod_template/control.lua @@ -157,6 +157,7 @@ function on_player_created(event) {%- if silo == 2 %} check_spawn_silo(game.players[event.player_index].force) {%- endif %} + dumpInfo(player.force) end script.on_event(defines.events.on_player_created, on_player_created) @@ -491,6 +492,7 @@ commands.add_command("ap-sync", "Used by the Archipelago client to get progress ["death_link"] = DEATH_LINK, ["energy"] = chain_lookup(forcedata, "energy"), ["energy_bridges"] = chain_lookup(forcedata, "energy_bridges"), + ["multiplayer"] = #game.players > 1, } for tech_name, tech in pairs(force.technologies) do @@ -600,5 +602,9 @@ commands.add_command("toggle-ap-send-filter", "Toggle filtering of item sends th log("Player command toggle-ap-send-filter") -- notifies client end) +commands.add_command("toggle-ap-chat", "Toggle sending of chat messages from players on the Factorio server to Archipelago.", function(call) + log("Player command toggle-ap-chat") -- notifies client +end) + -- data progressive_technologies = {{ dict_to_lua(progressive_technology_table) }} diff --git a/worlds/factorio/docs/setup_en.md b/worlds/factorio/docs/setup_en.md index 8b24da13d5..73ff5c8c48 100644 --- a/worlds/factorio/docs/setup_en.md +++ b/worlds/factorio/docs/setup_en.md @@ -132,6 +132,8 @@ This allows you to host your own Factorio game. For additional client features, issue the `/help` command in the Archipelago Client. Once connected to the AP server, you can also issue the `!help` command to learn about additional commands like `!hint`. +For more information about the commands you can use, see the [Commands Guide](/tutorial/Archipelago/commands/en) and +[Other Settings](#other-settings). ## Allowing Other People to Join Your Game @@ -148,10 +150,20 @@ you can also issue the `!help` command to learn about additional commands like ` - Type `/toggle-ap-send-filter` in-game - Type `/toggle_send_filter` in the Archipelago Client - In your `host.yaml` set - ``` - factorio_options: - filter_item_sends: true - ``` +``` +factorio_options: + filter_item_sends: true +``` +- By default, in-game chat is bridged to Archipelago. If you prefer to be able to speak privately, you can disable this + feature by doing one of the following: + - Type `/toggle-ap-chat` in-game + - Type `/toggle_chat` in the Archipelago Client + - In your `host.yaml` set +``` +factorio_options: + bridge_chat_out: false +``` + Note that this will also disable `!` commands from within the game, and that it will not affect incoming chat. ## Troubleshooting @@ -159,13 +171,6 @@ In case any problems should occur, the Archipelago Client will create a file `Fa contents of this file may help you troubleshoot an issue on your own and is vital for requesting help from other people in Archipelago. -## Commands in game - -Once you have connected to the server successfully using the Archipelago Factorio Client you should see a message -stating you can get help using Archipelago commands by typing `!help`. Commands cannot currently be sent from within -the Factorio session, but you can send them from the Archipelago Factorio Client. For more information about the commands -you can use see the [commands guide](/tutorial/Archipelago/commands/en). - ## Additional Resources - Alternate Tutorial by From c09e089f9df6b0b0dae4780c7d19c8e4de0dd837 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Thu, 27 Oct 2022 16:35:18 -0700 Subject: [PATCH 14/17] Docs: Zillion: RetroArch core and `early_items` recommendation. (#1150) * add Genesis Plus GX to Zillion docs * zillion early items recommendation --- worlds/zillion/docs/setup_en.md | 17 ++++++++++++++--- worlds/zillion/options.py | 8 +------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/worlds/zillion/docs/setup_en.md b/worlds/zillion/docs/setup_en.md index 43a1296748..63a0ec5a5b 100644 --- a/worlds/zillion/docs/setup_en.md +++ b/worlds/zillion/docs/setup_en.md @@ -15,7 +15,9 @@ RetroArch 1.9.x will not work, as it is older than 1.10.3. 1. Enter the RetroArch main menu screen. -2. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and install "Sega - MS/GG (SMS Plus GX)". +2. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and install one of these cores: + - "Sega - MS/GG (SMS Plus GX)" + - "Sega - MS/GG/MD/CD (Genesis Plus GX) 3. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 4. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default Network Command Port at 55355. @@ -47,6 +49,15 @@ guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en) The [player settings page](/games/Zillion/player-settings) on the website allows you to configure your personal settings and export a config file from them. +### Advanced settings + +The [advanced settings page](/tutorial/Archipelago/advanced_settings/en) describes more options you can put in your configuration file. + - A recommended setting for Zillion is: +``` + early_items: + Scope: 1 +``` + ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the [YAML Validator page](/mysterycheck). @@ -63,7 +74,7 @@ If you would like to validate your config file to make sure it works, you may do - Windows - Double-click on your patch file. The Zillion Client will launch automatically, and create your ROM in the location of the patch file. -6. Open the ROM in RetroArch using the core "SMS Plus GX". +6. Open the ROM in RetroArch using the core "SMS Plus GX" or "Genesis Plus GX". - For a single player game, any emulator (or a Sega Master System) can be used, but there are additional features with RetroArch and the Zillion Client. - If you press reset or restore a save state and return to the surface in the game, the Zillion Client will keep open all the doors that you have opened. @@ -80,7 +91,7 @@ If you would like to validate your config file to make sure it works, you may do - This should automatically launch the client, and will also create your ROM in the same place as your patch file. 3. Connect to the client. - Use RetroArch to open the ROM that was generated. - - Be sure to select the **SMS Plus GX** core. This core will allow external tools to read RAM data. + - Be sure to select the **SMS Plus GX** core or the **Genesis Plus GX** core. These cores will allow external tools to read RAM data. 4. Connect to the Archipelago Server. - The patch file which launched your client should have automatically connected you to the AP Server. There are a few reasons this may not happen however, including if the game is hosted on the website but was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host for the address of the server, and copy/paste it into the "Server" input field then press enter. - The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected". diff --git a/worlds/zillion/options.py b/worlds/zillion/options.py index 4e7d3b6a70..d24b5fd582 100644 --- a/worlds/zillion/options.py +++ b/worlds/zillion/options.py @@ -26,11 +26,6 @@ class ZillionContinues(SpecialRange): } -class ZillionEarlyScope(Toggle): - """ whether to make sure there is a scope available early """ - display_name = "early scope" - - class ZillionFloppyReq(Range): """ how many floppy disks are required """ range_start = 0 @@ -227,7 +222,6 @@ class ZillionRoomGen(Toggle): zillion_options: Dict[str, AssembleOptions] = { "continues": ZillionContinues, - # "early_scope": ZillionEarlyScope, # TODO: implement "floppy_req": ZillionFloppyReq, "gun_levels": ZillionGunLevels, "jump_levels": ZillionJumpLevels, @@ -371,7 +365,7 @@ def validate(world: "MultiWorld", p: int) -> "Tuple[ZzOptions, Counter[str]]": floppy_req.value, wo.continues[p].value, wo.randomize_alarms[p].value, - False, # wo.early_scope[p].value, + False, # early scope can be done with AP early_items True, # balance defense starting_cards.value, bool(room_gen.value) From 813ea6ef8b6d08ec7e89007997198b6bb9e3980c Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Thu, 27 Oct 2022 19:43:02 -0400 Subject: [PATCH 15/17] [SM64] Separate Entrance Shuffle pools option and MIPS cost option improvement (#1137) * Add separate pool option for entrance shuffle and swap MIPS costs if MIPS1Cost is greater * Changes based on N00by's suggestions * split into secret_entrance_ids and course_entrance_ids --- worlds/sm64ex/Options.py | 5 +++-- worlds/sm64ex/Rules.py | 17 +++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/worlds/sm64ex/Options.py b/worlds/sm64ex/Options.py index f29a65c58d..88d27bb3ea 100644 --- a/worlds/sm64ex/Options.py +++ b/worlds/sm64ex/Options.py @@ -46,7 +46,7 @@ class MIPS1Cost(Range): class MIPS2Cost(Range): - """How many stars are required to spawn MIPS the secound time. Must be bigger or equal MIPS1Cost""" + """How many stars are required to spawn MIPS the second time.""" range_start = 0 range_end = 80 default = 50 @@ -72,7 +72,8 @@ class AreaRandomizer(Choice): display_name = "Entrance Randomizer" option_Off = 0 option_Courses_Only = 1 - option_Courses_and_Secrets = 2 + option_Courses_and_Secrets_Separate = 2 + option_Courses_and_Secrets = 3 class BuddyChecks(Toggle): diff --git a/worlds/sm64ex/Rules.py b/worlds/sm64ex/Rules.py index 2462206246..2397f2c807 100644 --- a/worlds/sm64ex/Rules.py +++ b/worlds/sm64ex/Rules.py @@ -11,13 +11,14 @@ def fix_reg(entrance_ids, reg, invalidspot, swaplist, world): def set_rules(world, player: int, area_connections): destination_regions = list(range(13)) + [12,13,14] + list(range(15,15+len(sm64secrets))) # Two instances of Destination Course THI. Past normal course idx are secret regions - if world.AreaRandomizer[player].value == 0: - entrance_ids = list(range(len(sm64paintings + sm64secrets))) - if world.AreaRandomizer[player].value >= 1: # Some randomization is happening, randomize Courses - entrance_ids = list(range(len(sm64paintings))) - world.random.shuffle(entrance_ids) - entrance_ids = entrance_ids + list(range(len(sm64paintings), len(sm64paintings) + len(sm64secrets))) - if world.AreaRandomizer[player].value == 2: # Secret Regions as well + secret_entrance_ids = list(range(len(sm64paintings), len(sm64paintings) + len(sm64secrets))) + course_entrance_ids = list(range(len(sm64paintings))) + if world.AreaRandomizer[player].value >= 1: # Some randomization is happening, randomize Courses + world.random.shuffle(course_entrance_ids) + if world.AreaRandomizer[player].value == 2: # Randomize Secrets as well + world.random.shuffle(secret_entrance_ids) + entrance_ids = course_entrance_ids + secret_entrance_ids + if world.AreaRandomizer[player].value == 3: # Randomize Courses and Secrets in one pool world.random.shuffle(entrance_ids) # Guarantee first entrance is a course swaplist = list(range(len(entrance_ids))) @@ -117,7 +118,7 @@ def set_rules(world, player: int, area_connections): add_rule(world.get_location("Toad (Third Floor)", player), lambda state: state.can_reach("Third Floor", 'Region', player) and state.has("Power Star", player, 35)) if world.MIPS1Cost[player].value > world.MIPS2Cost[player].value: - world.MIPS2Cost[player].value = world.MIPS1Cost[player].value + (world.MIPS2Cost[player].value, world.MIPS1Cost[player].value) = (world.MIPS1Cost[player].value, world.MIPS2Cost[player].value) add_rule(world.get_location("MIPS 1", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, world.MIPS1Cost[player].value)) add_rule(world.get_location("MIPS 2", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, world.MIPS2Cost[player].value)) From e6c6b00109c6aa6e31816bb50b9fb74e7f234a19 Mon Sep 17 00:00:00 2001 From: AkumaGath17 <98751776+AkumaGath17@users.noreply.github.com> Date: Fri, 28 Oct 2022 10:34:46 +0200 Subject: [PATCH 16/17] Minecraft: Two by two logical requirement fix (#1152) * [Minecraft] Two by two logical requirement fix * Two by two update * Two by Two logical fix [Description in order] * Two by Two fix [Bucket only= False] Along with the others isolated items checks --- test/minecraft/TestAdvancements.py | 5 +++-- worlds/minecraft/Rules.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/minecraft/TestAdvancements.py b/test/minecraft/TestAdvancements.py index 6ddebcbfd2..f86d5a7333 100644 --- a/test/minecraft/TestAdvancements.py +++ b/test/minecraft/TestAdvancements.py @@ -312,9 +312,10 @@ class TestAdvancements(TestMinecraft): ["Two by Two", False, [], ['Flint and Steel']], ["Two by Two", False, [], ['Progressive Tools']], ["Two by Two", False, [], ['Progressive Weapons']], - ["Two by Two", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], + ["Two by Two", False, [], ['Bucket']], + ["Two by Two", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], + ["Two by Two", False, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], ["Two by Two", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons']], - ["Two by Two", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], ]) def test_42023(self): diff --git a/worlds/minecraft/Rules.py b/worlds/minecraft/Rules.py index ecdb459769..06576d7012 100644 --- a/worlds/minecraft/Rules.py +++ b/worlds/minecraft/Rules.py @@ -173,7 +173,7 @@ def set_advancement_rules(world: MultiWorld, player: int): state.can_reach("Hero of the Village", "Location", player)) # Bad Omen, Hero of the Village set_rule(world.get_location("Bullseye", player), lambda state: state.has("Archery", player) and state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) set_rule(world.get_location("Spooky Scary Skeleton", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Two by Two", player), lambda state: state._mc_has_iron_ingots(player) and state._mc_can_adventure(player)) # shears > seagrass > turtles; nether > striders; gold carrots > horses skips ingots + set_rule(world.get_location("Two by Two", player), lambda state: state._mc_has_iron_ingots(player) and state.has("Bucket", player) and state._mc_can_adventure(player)) # shears > seagrass > turtles; buckets of tropical fish > axolotls; nether > striders; gold carrots > horses skips ingots # set_rule(world.get_location("Stone Age", player), lambda state: True) set_rule(world.get_location("Two Birds, One Arrow", player), lambda state: state._mc_craft_crossbow(player) and state._mc_can_enchant(player)) # set_rule(world.get_location("We Need to Go Deeper", player), lambda state: True) From 80db8a33af3d5b1556a2caf278d2c2ca64f9a6a0 Mon Sep 17 00:00:00 2001 From: CaitSith2 Date: Fri, 28 Oct 2022 02:45:18 -0700 Subject: [PATCH 17/17] Don't leak info about what exists or not if player can't afford the hint (#1146) --- MultiServer.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/MultiServer.py b/MultiServer.py index bab762c84b..1a672afaa6 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -1332,6 +1332,8 @@ class ClientMessageProcessor(CommonCommandProcessor): def get_hints(self, input_text: str, for_location: bool = False) -> bool: points_available = get_client_points(self.ctx, self.client) + cost = self.ctx.get_hint_cost(self.client.slot) + if not input_text: hints = {hint.re_check(self.ctx, self.client.team) for hint in self.ctx.hints[self.client.team, self.client.slot]} @@ -1386,7 +1388,6 @@ class ClientMessageProcessor(CommonCommandProcessor): return False if hints: - cost = self.ctx.get_hint_cost(self.client.slot) new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot] old_hints = set(hints) - new_hints if old_hints: @@ -1436,7 +1437,12 @@ class ClientMessageProcessor(CommonCommandProcessor): return True else: - self.output("Nothing found. Item/Location may not exist.") + if points_available >= cost: + self.output("Nothing found. Item/Location may not exist.") + else: + self.output(f"You can't afford the hint. " + f"You have {points_available} points and need at least " + f"{self.ctx.get_hint_cost(self.client.slot)}.") return False @mark_raw