diff --git a/BaseClasses.py b/BaseClasses.py index a5de1689a7fe..0d4f34e51445 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -194,7 +194,9 @@ def add_group(self, name: str, game: str, players: AbstractSet[int] = frozenset( self.player_types[new_id] = NetUtils.SlotType.group world_type = AutoWorld.AutoWorldRegister.world_types[game] self.worlds[new_id] = world_type.create_group(self, new_id, players) - self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id]) + self.worlds[new_id].collect_item = AutoWorld.World.collect_item.__get__(self.worlds[new_id]) + self.worlds[new_id].collect = AutoWorld.World.collect.__get__(self.worlds[new_id]) + self.worlds[new_id].remove = AutoWorld.World.remove.__get__(self.worlds[new_id]) self.player_name[new_id] = name new_group = self.groups[new_id] = Group(name=name, game=game, players=players, @@ -720,7 +722,7 @@ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, qu if new_region in reachable_regions: blocked_connections.remove(connection) elif connection.can_reach(self): - assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region" + assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region" reachable_regions.add(new_region) blocked_connections.remove(connection) blocked_connections.update(new_region.exits) @@ -946,6 +948,7 @@ def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) self.player = player def can_reach(self, state: CollectionState) -> bool: + assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" if self.parent_region.can_reach(state) and self.access_rule(state): if not self.hide_path and not self in state.path: state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None))) @@ -1166,7 +1169,7 @@ def can_fill(self, state: CollectionState, item: Item, check_access: bool = True def can_reach(self, state: CollectionState) -> bool: # Region.can_reach is just a cache lookup, so placing it first for faster abort on average - assert self.parent_region, "Can't reach location without region" + assert self.parent_region, f"called can_reach on a Location \"{self}\" with no parent_region" return self.parent_region.can_reach(state) and self.access_rule(state) def place_locked_item(self, item: Item): diff --git a/CommonClient.py b/CommonClient.py index 6bdd8fc819da..296c10ed4b4e 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -45,10 +45,21 @@ def get_ssl_context(): class ClientCommandProcessor(CommandProcessor): + """ + The Command Processor will parse every method of the class that starts with "_cmd_" as a command to be called + when parsing user input, i.e. _cmd_exit will be called when the user sends the command "/exit". + + The decorator @mark_raw can be imported from MultiServer and tells the parser to only split on the first + space after the command i.e. "/exit one two three" will be passed in as method("one two three") with mark_raw + and method("one", "two", "three") without. + + In addition all docstrings for command methods will be displayed to the user on launch and when using "/help" + """ def __init__(self, ctx: CommonContext): self.ctx = ctx def output(self, text: str): + """Helper function to abstract logging to the CommonClient UI""" logger.info(text) def _cmd_exit(self) -> bool: @@ -164,13 +175,14 @@ def _cmd_ready(self): async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") def default(self, raw: str): + """The default message parser to be used when parsing any messages that do not match a command""" raw = self.ctx.on_user_say(raw) if raw: async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") class CommonContext: - # Should be adjusted as needed in subclasses + # The following attributes are used to Connect and should be adjusted as needed in subclasses tags: typing.Set[str] = {"AP"} game: typing.Optional[str] = None items_handling: typing.Optional[int] = None @@ -429,7 +441,10 @@ async def get_username(self): self.auth = await self.console_input() async def send_connect(self, **kwargs: typing.Any) -> None: - """ send `Connect` packet to log in to server """ + """ + Send a `Connect` packet to log in to the server, + additional keyword args can override any value in the connection packet + """ payload = { 'cmd': 'Connect', 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, @@ -439,6 +454,7 @@ async def send_connect(self, **kwargs: typing.Any) -> None: if kwargs: payload.update(kwargs) await self.send_msgs([payload]) + await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}]) async def console_input(self) -> str: if self.ui: @@ -459,6 +475,7 @@ def cancel_autoreconnect(self) -> bool: return False def slot_concerns_self(self, slot) -> bool: + """Helper function to abstract player groups, should be used instead of checking slot == self.slot directly.""" if slot == self.slot: return True if slot in self.slot_info: @@ -466,6 +483,7 @@ def slot_concerns_self(self, slot) -> bool: return False def is_echoed_chat(self, print_json_packet: dict) -> bool: + """Helper function for filtering out messages sent by self.""" return print_json_packet.get("type", "") == "Chat" \ and print_json_packet.get("team", None) == self.team \ and print_json_packet.get("slot", None) == self.slot @@ -497,13 +515,14 @@ def on_user_say(self, text: str) -> typing.Optional[str]: """Gets called before sending a Say to the server from the user. Returned text is sent, or sending is aborted if None is returned.""" return text - + def on_ui_command(self, text: str) -> None: """Gets called by kivy when the user executes a command starting with `/` or `!`. The command processor is still called; this is just intended for command echoing.""" self.ui.print_json([{"text": text, "type": "color", "color": "orange"}]) def update_permissions(self, permissions: typing.Dict[str, int]): + """Internal method to parse and save server permissions from RoomInfo""" for permission_name, permission_flag in permissions.items(): try: flag = Permission(permission_flag) @@ -613,6 +632,7 @@ def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: logger.info(f"DeathLink: Received from {data['source']}") async def send_death(self, death_text: str = ""): + """Helper function to send a deathlink using death_text as the unique death cause string.""" if self.server and self.server.socket: logger.info("DeathLink: Sending death to your friends...") self.last_death_link = time.time() @@ -626,6 +646,7 @@ async def send_death(self, death_text: str = ""): }]) async def update_death_link(self, death_link: bool): + """Helper function to set Death Link connection tag on/off and update the connection if already connected.""" old_tags = self.tags.copy() if death_link: self.tags.add("DeathLink") @@ -635,7 +656,7 @@ async def update_death_link(self, death_link: bool): await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]: - """Displays an error messagebox""" + """Displays an error messagebox in the loaded Kivy UI. Override if using a different UI framework""" if not self.ui: return None title = title or "Error" @@ -987,6 +1008,7 @@ async def console_loop(ctx: CommonContext): def get_base_parser(description: typing.Optional[str] = None): + """Base argument parser to be reused for components subclassing off of CommonClient""" import argparse parser = argparse.ArgumentParser(description=description) parser.add_argument('--connect', default=None, help='Address of the multiworld host.') @@ -1037,6 +1059,7 @@ async def main(args): parser.add_argument("url", nargs="?", help="Archipelago connection url") args = parser.parse_args(args) + # handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost if args.url: url = urllib.parse.urlparse(args.url) if url.scheme == "archipelago": @@ -1048,6 +1071,7 @@ async def main(args): else: parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281") + # use colorama to display colored text highlighting on windows colorama.init() asyncio.run(main(args)) diff --git a/Main.py b/Main.py index 5a0f5c98bcc4..4008ca5e9017 100644 --- a/Main.py +++ b/Main.py @@ -338,6 +338,7 @@ def precollect_hint(location): "seed_name": multiworld.seed_name, "spheres": spheres, "datapackage": data_package, + "race_mode": int(multiworld.is_race), } AutoWorld.call_all(multiworld, "modify_multidata", multidata) diff --git a/MultiServer.py b/MultiServer.py index e0b137fd68ce..0fe950b5e4f3 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -15,6 +15,7 @@ import operator import pickle import random +import shlex import threading import time import typing @@ -427,6 +428,8 @@ def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.A use_embedded_server_options: bool): self.read_data = {} + # there might be a better place to put this. + self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0) mdata_ver = decoded_obj["minimum_versions"]["server"] if mdata_ver > version_tuple: raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}," @@ -1150,7 +1153,7 @@ def __call__(self, raw: str) -> typing.Optional[bool]: if not raw: return try: - command = raw.split() + command = shlex.split(raw, comments=False) basecommand = command[0] if basecommand[0] == self.marker: method = self.commands.get(basecommand[1:].lower(), None) diff --git a/WargrooveClient.py b/WargrooveClient.py index 39da044d659c..f9971f7a6c05 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -267,9 +267,7 @@ class WargrooveManager(GameManager): def build(self): container = super().build() - panel = TabbedPanelItem(text="Wargroove") - panel.content = self.build_tracker() - self.tabs.add_widget(panel) + self.add_client_tab("Wargroove", self.build_tracker()) return container def build_tracker(self) -> TrackerLayout: diff --git a/WebHostLib/templates/genericTracker.html b/WebHostLib/templates/genericTracker.html index 5a533204083b..947cf2837278 100644 --- a/WebHostLib/templates/genericTracker.html +++ b/WebHostLib/templates/genericTracker.html @@ -99,14 +99,18 @@ {% if hint.finding_player == player %} {{ player_names_with_alias[(team, hint.finding_player)] }} {% else %} - {{ player_names_with_alias[(team, hint.finding_player)] }} + + {{ player_names_with_alias[(team, hint.finding_player)] }} + {% endif %} {% if hint.receiving_player == player %} {{ player_names_with_alias[(team, hint.receiving_player)] }} {% else %} - {{ player_names_with_alias[(team, hint.receiving_player)] }} + + {{ player_names_with_alias[(team, hint.receiving_player)] }} + {% endif %} {{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }} diff --git a/WebHostLib/templates/userContent.html b/WebHostLib/templates/userContent.html index 71a0f6747bc3..4e3747f4f952 100644 --- a/WebHostLib/templates/userContent.html +++ b/WebHostLib/templates/userContent.html @@ -1,5 +1,21 @@ {% extends 'tablepage.html' %} +{%- macro games(slots) -%} + {%- set gameList = [] -%} + {%- set maxGamesToShow = 10 -%} + + {%- for slot in (slots|list|sort(attribute="player_id"))[:maxGamesToShow] -%} + {% set player = "#" + slot["player_id"]|string + " " + slot["player_name"] + " : " + slot["game"] -%} + {% set _ = gameList.append(player) -%} + {%- endfor -%} + + {%- if slots|length > maxGamesToShow -%} + {% set _ = gameList.append("... and " + (slots|length - maxGamesToShow)|string + " more") -%} + {%- endif -%} + + {{ gameList|join('\n') }} +{%- endmacro -%} + {% block head %} {{ super() }} User Content @@ -33,10 +49,12 @@

Your Rooms

{{ room.seed.id|suuid }} {{ room.id|suuid }} - {{ room.seed.slots|length }} + + {{ room.seed.slots|length }} + {{ room.creation_time.strftime("%Y-%m-%d %H:%M") }} {{ room.last_activity.strftime("%Y-%m-%d %H:%M") }} - Delete next maintenance. + Delete next maintenance. {% endfor %} @@ -60,10 +78,15 @@

Your Seeds

{% for seed in seeds %} {{ seed.id|suuid }} - {% if seed.multidata %}{{ seed.slots|length }}{% else %}1{% endif %} + + {% if seed.multidata %} + {{ seed.slots|length }} + {% else %} + 1 + {% endif %} {{ seed.creation_time.strftime("%Y-%m-%d %H:%M") }} - Delete next maintenance. + Delete next maintenance. {% endfor %} diff --git a/docs/network protocol.md b/docs/network protocol.md index f8080fecc879..1c4579c4066f 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -395,6 +395,7 @@ Some special keys exist with specific return data, all of them have the prefix ` | item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. | | location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. | | client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. | +| race_mode | int | 0 if race mode is disabled, and 1 if it's enabled. | ### Set Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package. diff --git a/kvui.py b/kvui.py index 65cf52c7a4aa..74d8ad06734a 100644 --- a/kvui.py +++ b/kvui.py @@ -243,6 +243,9 @@ def get_text(self): f"\nYou currently have {ctx.hint_points} points." elif ctx.hint_cost == 0: text += "\n!hint is free to use." + if ctx.stored_data and "_read_race_mode" in ctx.stored_data: + text += "\nRace mode is enabled." \ + if ctx.stored_data["_read_race_mode"] else "\nRace mode is disabled." else: text += f"\nYou are not authenticated yet." @@ -536,9 +539,8 @@ def connect_bar_validate(sender): # show Archipelago tab if other logging is present self.tabs.add_widget(panel) - hint_panel = TabbedPanelItem(text="Hints") - self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser) - self.tabs.add_widget(hint_panel) + hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser)) + self.log_panels["Hints"] = hint_panel.content if len(self.logging_pairs) == 1: self.tabs.default_tab_text = "Archipelago" @@ -572,6 +574,14 @@ def connect_bar_validate(sender): return self.container + def add_client_tab(self, title: str, content: Widget) -> Widget: + """Adds a new tab to the client window with a given title, and provides a given Widget as its content. + Returns the new tab widget, with the provided content being placed on the tab as content.""" + new_tab = TabbedPanelItem(text=title) + new_tab.content = content + self.tabs.add_widget(new_tab) + return new_tab + def update_texts(self, dt): if hasattr(self.tabs.content.children[0], "fix_heights"): self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream diff --git a/test/multiworld/test_multiworlds.py b/test/multiworld/test_multiworlds.py index 8415ac4c8429..3c1d0e4544eb 100644 --- a/test/multiworld/test_multiworlds.py +++ b/test/multiworld/test_multiworlds.py @@ -71,7 +71,7 @@ def test_two_player_single_game_fills(self) -> None: for world in self.multiworld.worlds.values(): world.options.accessibility.value = Accessibility.option_full self.assertSteps(gen_steps) - with self.subTest("filling multiworld", seed=self.multiworld.seed): - distribute_items_restrictive(self.multiworld) - call_all(self.multiworld, "post_fill") - self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") + with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed): + distribute_items_restrictive(self.multiworld) + call_all(self.multiworld, "post_fill") + self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 19ec9a14a8c7..f7dae2b92750 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -342,7 +342,7 @@ def __getattr__(self, item: str) -> Any: # overridable methods that get called by Main.py, sorted by execution order # can also be implemented as a classmethod and called "stage_", - # in that case the MultiWorld object is passed as an argument, and it gets called once for the entire multiworld. + # in that case the MultiWorld object is passed as the first argument, and it gets called once for the entire multiworld. # An example of this can be found in alttp as stage_pre_fill @classmethod diff --git a/worlds/_bizhawk/__init__.py b/worlds/_bizhawk/__init__.py index 74f2954b984b..3627f385c2d3 100644 --- a/worlds/_bizhawk/__init__.py +++ b/worlds/_bizhawk/__init__.py @@ -223,8 +223,8 @@ async def set_message_interval(ctx: BizHawkContext, value: float) -> None: raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}") -async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]], - guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> typing.Optional[typing.List[bytes]]: +async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]], + guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]: """Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected value. @@ -266,7 +266,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[ return ret -async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]]) -> typing.List[bytes]: +async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]]) -> typing.List[bytes]: """Reads data at 1 or more addresses. Items in `read_list` should be organized `(address, size, domain)` where @@ -278,8 +278,8 @@ async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int return await guarded_read(ctx, read_list, []) -async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]], - guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> bool: +async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]], + guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> bool: """Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value. Items in `write_list` should be organized `(address, value, domain)` where @@ -316,7 +316,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tupl return True -async def write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> None: +async def write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> None: """Writes data to 1 or more addresses. Items in write_list should be organized `(address, value, domain)` where diff --git a/worlds/ahit/Client.py b/worlds/ahit/Client.py index 2cd67e468294..cbb5f2a13d1f 100644 --- a/worlds/ahit/Client.py +++ b/worlds/ahit/Client.py @@ -4,7 +4,7 @@ import functools from copy import deepcopy from typing import List, Any, Iterable -from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem +from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem, NetworkPlayer from MultiServer import Endpoint from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser @@ -101,12 +101,35 @@ def update_items(self): def on_package(self, cmd: str, args: dict): if cmd == "Connected": - self.connected_msg = encode([args]) + json = args + # This data is not needed and causes the game to freeze for long periods of time in large asyncs. + if "slot_info" in json.keys(): + json["slot_info"] = {} + if "players" in json.keys(): + me: NetworkPlayer + for n in json["players"]: + if n.slot == json["slot"] and n.team == json["team"]: + me = n + break + + # Only put our player info in there as we actually need it + json["players"] = [me] + if DEBUG: + print(json) + self.connected_msg = encode([json]) if self.awaiting_info: self.server_msgs.append(self.room_info) self.update_items() self.awaiting_info = False + elif cmd == "RoomUpdate": + # Same story as above + json = args + if "players" in json.keys(): + json["players"] = [] + + self.server_msgs.append(encode(json)) + elif cmd == "ReceivedItems": if args["index"] == 0: self.full_inventory.clear() @@ -166,6 +189,17 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None): await ctx.disconnect_proxy() break + if ctx.auth: + name = msg.get("name", "") + if name != "" and name != ctx.auth: + logger.info("Aborting proxy connection: player name mismatch from save file") + logger.info(f"Expected: {ctx.auth}, got: {name}") + text = encode([{"cmd": "PrintJSON", + "data": [{"text": "Connection aborted - player name mismatch"}]}]) + await ctx.send_msgs_proxy(text) + await ctx.disconnect_proxy() + break + if ctx.connected_msg and ctx.is_connected(): await ctx.send_msgs_proxy(ctx.connected_msg) ctx.update_items() diff --git a/worlds/ahit/DeathWishLocations.py b/worlds/ahit/DeathWishLocations.py index ef74cadcaa53..ce339c7c19bb 100644 --- a/worlds/ahit/DeathWishLocations.py +++ b/worlds/ahit/DeathWishLocations.py @@ -152,10 +152,10 @@ def create_dw_regions(world: "HatInTimeWorld"): for name in annoying_dws: world.excluded_dws.append(name) - if not world.options.DWEnableBonus or world.options.DWAutoCompleteBonuses: + if not world.options.DWEnableBonus and world.options.DWAutoCompleteBonuses: for name in death_wishes: world.excluded_bonuses.append(name) - elif world.options.DWExcludeAnnoyingBonuses: + if world.options.DWExcludeAnnoyingBonuses and not world.options.DWAutoCompleteBonuses: for name in annoying_bonuses: world.excluded_bonuses.append(name) diff --git a/worlds/ahit/__init__.py b/worlds/ahit/__init__.py index dd5e88abbc66..14cf13ec346d 100644 --- a/worlds/ahit/__init__.py +++ b/worlds/ahit/__init__.py @@ -253,7 +253,8 @@ def fill_slot_data(self) -> dict: else: item_name = loc.item.name - shop_item_names.setdefault(str(loc.address), item_name) + shop_item_names.setdefault(str(loc.address), + f"{item_name} ({self.multiworld.get_player_name(loc.item.player)})") slot_data["ShopItemNames"] = shop_item_names diff --git a/worlds/bumpstik/__init__.py b/worlds/bumpstik/__init__.py index fe261dc94d30..ffe9efd2de87 100644 --- a/worlds/bumpstik/__init__.py +++ b/worlds/bumpstik/__init__.py @@ -125,6 +125,6 @@ def set_rules(self): lambda state: state.has("Hazard Bumper", self.player, 25) self.multiworld.completion_condition[self.player] = \ - lambda state: state.has("Booster Bumper", self.player, 5) and \ - state.has("Treasure Bumper", self.player, 32) + lambda state: state.has_all_counts({"Booster Bumper": 5, "Treasure Bumper": 32, "Hazard Bumper": 25}, \ + self.player) diff --git a/worlds/dark_souls_3/docs/setup_en.md b/worlds/dark_souls_3/docs/setup_en.md index ed90289a8baf..9755cce1c6a8 100644 --- a/worlds/dark_souls_3/docs/setup_en.md +++ b/worlds/dark_souls_3/docs/setup_en.md @@ -3,7 +3,7 @@ ## Required Software - [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/) -- [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases) +- [Dark Souls III AP Client](https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest) ## Optional Software @@ -11,8 +11,9 @@ ## Setting Up -First, download the client from the link above. It doesn't need to go into any particular directory; -it'll automatically locate _Dark Souls III_ in your Steam installation folder. +First, download the client from the link above (`DS3.Archipelago.*.zip`). It doesn't need to go +into any particular directory; it'll automatically locate _Dark Souls III_ in your Steam +installation folder. Version 3.0.0 of the randomizer _only_ supports the latest version of _Dark Souls III_, 1.15.2. This is the latest version, so you don't need to do any downpatching! However, if you've already @@ -35,8 +36,9 @@ randomized item and (optionally) enemy locations. You only need to do this once To run _Dark Souls III_ in Archipelago mode: -1. Start Steam. **Do not run in offline mode.** The mod will make sure you don't connect to the - DS3 servers, and running Steam in offline mode will make certain scripted invaders fail to spawn. +1. Start Steam. **Do not run in offline mode.** Running Steam in offline mode will make certain + scripted invaders fail to spawn. Instead, change the game itself to offline mode on the menu + screen. 2. Run `launchmod_darksouls3.bat`. This will start _Dark Souls III_ as well as a command prompt that you can use to interact with the Archipelago server. @@ -52,4 +54,21 @@ To run _Dark Souls III_ in Archipelago mode: ### Where do I get a config file? The [Player Options](/games/Dark%20Souls%20III/player-options) page on the website allows you to -configure your personal options and export them into a config file. +configure your personal options and export them into a config file. The [AP client archive] also +includes an options template. + +[AP client archive]: https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest + +### Does this work with Proton? + +The *Dark Souls III* Archipelago randomizer supports running on Linux under Proton. There are a few +things to keep in mind: + +* Because `DS3Randomizer.exe` relies on the .NET runtime, you'll need to install + the [.NET Runtime] under **plain [WINE]**, then run `DS3Randomizer.exe` under + plain WINE as well. It won't work as a Proton app! + +* To run the game itself, just run `launchmod_darksouls3.bat` under Proton. + +[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0 +[WINE]: https://www.winehq.org/ diff --git a/worlds/doom_1993/Locations.py b/worlds/doom_1993/Locations.py index 2cbb9b9d150e..90a6916cd716 100644 --- a/worlds/doom_1993/Locations.py +++ b/worlds/doom_1993/Locations.py @@ -2214,13 +2214,13 @@ class LocationDict(TypedDict, total=False): 'map': 2, 'index': 217, 'doom_type': 2006, - 'region': "Perfect Hatred (E4M2) Blue"}, + 'region': "Perfect Hatred (E4M2) Upper"}, 351367: {'name': 'Perfect Hatred (E4M2) - Exit', 'episode': 4, 'map': 2, 'index': -1, 'doom_type': -1, - 'region': "Perfect Hatred (E4M2) Blue"}, + 'region': "Perfect Hatred (E4M2) Upper"}, 351368: {'name': 'Sever the Wicked (E4M3) - Invulnerability', 'episode': 4, 'map': 3, diff --git a/worlds/doom_1993/Regions.py b/worlds/doom_1993/Regions.py index f013bdceaf07..c32f7b470101 100644 --- a/worlds/doom_1993/Regions.py +++ b/worlds/doom_1993/Regions.py @@ -502,13 +502,12 @@ class RegionDict(TypedDict, total=False): "episode":4, "connections":[ {"target":"Perfect Hatred (E4M2) Blue","pro":False}, - {"target":"Perfect Hatred (E4M2) Yellow","pro":False}]}, + {"target":"Perfect Hatred (E4M2) Yellow","pro":False}, + {"target":"Perfect Hatred (E4M2) Upper","pro":True}]}, {"name":"Perfect Hatred (E4M2) Blue", "connects_to_hub":False, "episode":4, - "connections":[ - {"target":"Perfect Hatred (E4M2) Main","pro":False}, - {"target":"Perfect Hatred (E4M2) Cave","pro":False}]}, + "connections":[{"target":"Perfect Hatred (E4M2) Upper","pro":False}]}, {"name":"Perfect Hatred (E4M2) Yellow", "connects_to_hub":False, "episode":4, @@ -518,7 +517,13 @@ class RegionDict(TypedDict, total=False): {"name":"Perfect Hatred (E4M2) Cave", "connects_to_hub":False, "episode":4, - "connections":[]}, + "connections":[{"target":"Perfect Hatred (E4M2) Main","pro":False}]}, + {"name":"Perfect Hatred (E4M2) Upper", + "connects_to_hub":False, + "episode":4, + "connections":[ + {"target":"Perfect Hatred (E4M2) Cave","pro":False}, + {"target":"Perfect Hatred (E4M2) Main","pro":False}]}, # Sever the Wicked (E4M3) {"name":"Sever the Wicked (E4M3) Main", diff --git a/worlds/doom_1993/Rules.py b/worlds/doom_1993/Rules.py index 4faeb4a27dbd..89b09ff9f250 100644 --- a/worlds/doom_1993/Rules.py +++ b/worlds/doom_1993/Rules.py @@ -403,9 +403,8 @@ def set_episode4_rules(player, multiworld, pro): state.has("Chaingun", player, 1)) and (state.has("Plasma gun", player, 1) or state.has("BFG9000", player, 1))) set_rule(multiworld.get_entrance("Hell Beneath (E4M1) Main -> Hell Beneath (E4M1) Blue", player), lambda state: - state.has("Shotgun", player, 1) or - state.has("Chaingun", player, 1) or - state.has("Hell Beneath (E4M1) - Blue skull key", player, 1)) + (state.has("Hell Beneath (E4M1) - Blue skull key", player, 1)) and (state.has("Shotgun", player, 1) or + state.has("Chaingun", player, 1))) # Perfect Hatred (E4M2) set_rule(multiworld.get_entrance("Hub -> Perfect Hatred (E4M2) Main", player), lambda state: diff --git a/worlds/generic/docs/plando_en.md b/worlds/generic/docs/plando_en.md index 161b1e465b33..1980e81cbcc4 100644 --- a/worlds/generic/docs/plando_en.md +++ b/worlds/generic/docs/plando_en.md @@ -22,9 +22,9 @@ enabled (opt-in). * You can add the necessary plando modules for your settings to the `requires` section of your YAML. Doing so will throw an error if the options that you need to generate properly are not enabled to ensure you will get the results you desire. Only enter in the plando modules that you are using here but it should look like: ```yaml - requires: - version: current.version.number - plando: bosses, items, texts, connections +requires: + version: current.version.number + plando: bosses, items, texts, connections ``` ## Item Plando @@ -74,77 +74,77 @@ A list of all available items and locations can be found in the [website's datap ### Examples ```yaml -plando_items: - # example block 1 - Timespinner - - item: - Empire Orb: 1 - Radiant Orb: 1 - location: Starter Chest 1 - from_pool: true - world: true - percentage: 50 - - # example block 2 - Ocarina of Time - - items: - Kokiri Sword: 1 - Biggoron Sword: 1 - Bow: 1 - Magic Meter: 1 - Progressive Strength Upgrade: 3 - Progressive Hookshot: 2 - locations: - - Deku Tree Slingshot Chest - - Dodongos Cavern Bomb Bag Chest - - Jabu Jabus Belly Boomerang Chest - - Bottom of the Well Lens of Truth Chest - - Forest Temple Bow Chest - - Fire Temple Megaton Hammer Chest - - Water Temple Longshot Chest - - Shadow Temple Hover Boots Chest - - Spirit Temple Silver Gauntlets Chest - world: false - - # example block 3 - Slay the Spire - - items: - Boss Relic: 3 - locations: - - Boss Relic 1 - - Boss Relic 2 - - Boss Relic 3 - - # example block 4 - Factorio - - items: - progressive-electric-energy-distribution: 2 - electric-energy-accumulators: 1 - progressive-turret: 2 - locations: - - military - - gun-turret - - logistic-science-pack - - steel-processing - percentage: 80 - force: true - -# example block 5 - Secret of Evermore - - items: - Levitate: 1 - Revealer: 1 - Energize: 1 - locations: - - Master Sword Pedestal - - Boss Relic 1 - world: true - count: 2 - -# example block 6 - A Link to the Past - - items: - Progressive Sword: 4 - world: - - BobsSlaytheSpire - - BobsRogueLegacy - count: - min: 1 - max: 4 + plando_items: + # example block 1 - Timespinner + - item: + Empire Orb: 1 + Radiant Orb: 1 + location: Starter Chest 1 + from_pool: true + world: true + percentage: 50 + + # example block 2 - Ocarina of Time + - items: + Kokiri Sword: 1 + Biggoron Sword: 1 + Bow: 1 + Magic Meter: 1 + Progressive Strength Upgrade: 3 + Progressive Hookshot: 2 + locations: + - Deku Tree Slingshot Chest + - Dodongos Cavern Bomb Bag Chest + - Jabu Jabus Belly Boomerang Chest + - Bottom of the Well Lens of Truth Chest + - Forest Temple Bow Chest + - Fire Temple Megaton Hammer Chest + - Water Temple Longshot Chest + - Shadow Temple Hover Boots Chest + - Spirit Temple Silver Gauntlets Chest + world: false + + # example block 3 - Slay the Spire + - items: + Boss Relic: 3 + locations: + - Boss Relic 1 + - Boss Relic 2 + - Boss Relic 3 + + # example block 4 - Factorio + - items: + progressive-electric-energy-distribution: 2 + electric-energy-accumulators: 1 + progressive-turret: 2 + locations: + - military + - gun-turret + - logistic-science-pack + - steel-processing + percentage: 80 + force: true + + # example block 5 - Secret of Evermore + - items: + Levitate: 1 + Revealer: 1 + Energize: 1 + locations: + - Master Sword Pedestal + - Boss Relic 1 + world: true + count: 2 + + # example block 6 - A Link to the Past + - items: + Progressive Sword: 4 + world: + - BobsSlaytheSpire + - BobsRogueLegacy + count: + min: 1 + max: 4 ``` 1. This block has a 50% chance to occur, and if it does, it will place either the Empire Orb or Radiant Orb on another player's Starter Chest 1 and removes the chosen item from the item pool. @@ -221,25 +221,25 @@ its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections). ### Examples ```yaml -plando_connections: - # example block 1 - A Link to the Past - - entrance: Cave Shop (Lake Hylia) - exit: Cave 45 - direction: entrance - - entrance: Cave 45 - exit: Cave Shop (Lake Hylia) - direction: entrance - - entrance: Agahnims Tower - exit: Old Man Cave Exit (West) - direction: exit - - # example block 2 - Minecraft - - entrance: Overworld Structure 1 - exit: Nether Fortress - direction: both - - entrance: Overworld Structure 2 - exit: Village - direction: both + plando_connections: + # example block 1 - A Link to the Past + - entrance: Cave Shop (Lake Hylia) + exit: Cave 45 + direction: entrance + - entrance: Cave 45 + exit: Cave Shop (Lake Hylia) + direction: entrance + - entrance: Agahnims Tower + exit: Old Man Cave Exit (West) + direction: exit + + # example block 2 - Minecraft + - entrance: Overworld Structure 1 + exit: Nether Fortress + direction: both + - entrance: Overworld Structure 2 + exit: Village + direction: both ``` 1. These connections are decoupled, so going into the Lake Hylia Cave Shop will take you to the inside of Cave 45, and diff --git a/worlds/hk/__init__.py b/worlds/hk/__init__.py index 860243ee952e..9ec77e6bf0cd 100644 --- a/worlds/hk/__init__.py +++ b/worlds/hk/__init__.py @@ -21,6 +21,16 @@ from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification, CollectionState from worlds.AutoWorld import World, LogicMixin, WebWorld +from settings import Group, Bool + + +class HollowKnightSettings(Group): + class DisableMapModSpoilers(Bool): + """Disallows the APMapMod from showing spoiler placements.""" + + disable_spoilers: typing.Union[DisableMapModSpoilers, bool] = False + + path_of_pain_locations = { "Soul_Totem-Path_of_Pain_Below_Thornskip", "Lore_Tablet-Path_of_Pain_Entrance", @@ -124,14 +134,25 @@ class HKWeb(WebWorld): - tutorials = [Tutorial( + setup_en = Tutorial( "Mod Setup and Use Guide", "A guide to playing Hollow Knight with Archipelago.", "English", "setup_en.md", "setup/en", ["Ijwu"] - )] + ) + + setup_pt_br = Tutorial( + setup_en.tutorial_name, + setup_en.description, + "Português Brasileiro", + "setup_pt_br.md", + "setup/pt_br", + ["JoaoVictor-FA"] + ) + + tutorials = [setup_en, setup_pt_br] bug_report_page = "https://github.com/Ijwu/Archipelago.HollowKnight/issues/new?assignees=&labels=bug%2C+needs+investigation&template=bug_report.md&title=" @@ -145,6 +166,7 @@ class HKWorld(World): game: str = "Hollow Knight" options_dataclass = HKOptions options: HKOptions + settings: typing.ClassVar[HollowKnightSettings] web = HKWeb() @@ -512,26 +534,16 @@ def fill_slot_data(self): for option_name in hollow_knight_options: option = getattr(self.options, option_name) try: + # exclude more complex types - we only care about int, bool, enum for player options; the client + # can get them back to the necessary type. optionvalue = int(option.value) - except TypeError: - pass # C# side is currently typed as dict[str, int], drop what doesn't fit - else: options[option_name] = optionvalue + except TypeError: + pass # 32 bit int slot_data["seed"] = self.random.randint(-2147483647, 2147483646) - # Backwards compatibility for shop cost data (HKAP < 0.1.0) - if not self.options.CostSanity: - for shop, terms in shop_cost_types.items(): - unit = cost_terms[next(iter(terms))].option - if unit == "Geo": - continue - slot_data[f"{unit}_costs"] = { - loc.name: next(iter(loc.costs.values())) - for loc in self.created_multi_locations[shop] - } - # HKAP 0.1.0 and later cost data. location_costs = {} for region in self.multiworld.get_regions(self.player): @@ -544,6 +556,8 @@ def fill_slot_data(self): slot_data["grub_count"] = self.grub_count + slot_data["is_race"] = self.settings.disable_spoilers or self.multiworld.is_race + return slot_data def create_item(self, name: str) -> HKItem: diff --git a/worlds/hk/docs/setup_pt_br.md b/worlds/hk/docs/setup_pt_br.md new file mode 100644 index 000000000000..9ae1ea89d566 --- /dev/null +++ b/worlds/hk/docs/setup_pt_br.md @@ -0,0 +1,52 @@ +# Guia de configuração para Hollow Knight no Archipelago + +## Programas obrigatórios +* Baixe e extraia o Lumafly Mod Manager (gerenciador de mods Lumafly) do [Site Lumafly](https://themulhima.github.io/Lumafly/). +* Uma cópia legal de Hollow Knight. + * Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas. + * Windows, Mac, e Linux (incluindo Steam Deck) são suportados. + +## Instalando o mod Archipelago Mod usando Lumafly +1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight. +2. Clique em "Install (instalar)" perto da opção "Archipelago" mod. + * Se quiser, instale também o "Archipelago Map Mod (mod do mapa do archipelago)" para usá-lo como rastreador dentro do jogo. +3. Abra o jogo, tudo preparado! + +### O que fazer se o Lumafly falha em encontrar a sua pasta de instalação +1. Encontre a pasta manualmente. + * Xbox Game Pass: + 1. Entre no seu aplicativo Xbox e mova seu mouse em cima de "Hollow Knight" na sua barra da esquerda. + 2. Clique nos 3 pontos depois clique gerenciar. + 3. Vá nos arquivos e selecione procurar. + 4. Clique em "Hollow Knight", depois em "Content (Conteúdo)", depois clique na barra com o endereço e a copie. + * Steam: + 1. Você provavelmente colocou sua biblioteca Steam num local não padrão. Se esse for o caso você provavelmente sabe onde está. + . Encontre sua biblioteca Steam, depois encontre a pasta do Hollow Knight e copie seu endereço. + * Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight` + * Linux/Steam Deck - `~/.local/share/Steam/steamapps/common/Hollow Knight` + * Mac - `~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app` +2. Rode o Lumafly como administrador e, quando ele perguntar pelo endereço do arquivo, cole o endereço do arquivo que você copiou. + +## Configurando seu arquivo YAML +### O que é um YAML e por que eu preciso de um? +Um arquivo YAML é a forma que você informa suas configurações do jogador para o Archipelago. +Olhe o [guia de configuração básica de um multiworld](/tutorial/Archipelago/setup/en) aqui no site do Archipelago para aprender mais. + +### Onde eu consigo o YAML? +Você pode usar a [página de configurações do jogador para Hollow Knight](/games/Hollow%20Knight/player-options) aqui no site do Archipelago +para gerar o YAML usando a interface gráfica. + +### Entrando numa partida de Archipelago no Hollow Knight +1. Começe o jogo depois de instalar todos os mods necessários. +2. Crie um **novo jogo salvo.** +3. Selecione o modo de jogo **Archipelago** do menu de seleção. +4. Coloque as configurações corretas do seu servidor Archipelago. +5. Aperte em **Começar**. O jogo vai travar por uns segundos enquanto ele coloca todos itens. +6. O jogo vai te colocar imediatamente numa partida randomizada. + * Se você está esperando uma contagem então espere ele cair antes de apertar começar. + * Ou clique em começar e pause o jogo enquanto estiver nele. + +## Dicas e outros comandos +Enquanto jogar um multiworld, você pode interagir com o servidor usando vários comandos listados no +[Guia de comandos](/tutorial/Archipelago/commands/en). Você pode usar o cliente de texto do Archipelago para isso, +que está incluido na ultima versão do [Archipelago software](https://github.com/ArchipelagoMW/Archipelago/releases/latest). diff --git a/worlds/kdl3/__init__.py b/worlds/kdl3/__init__.py index f01c82dd16a3..1b5acbe97a3c 100644 --- a/worlds/kdl3/__init__.py +++ b/worlds/kdl3/__init__.py @@ -325,7 +325,7 @@ def generate_basic(self) -> None: def generate_output(self, output_directory: str) -> None: try: - patch = KDL3ProcedurePatch() + patch = KDL3ProcedurePatch(player=self.player, player_name=self.player_name) patch_rom(self, patch) self.rom_name = patch.name diff --git a/worlds/kh1/Client.py b/worlds/kh1/Client.py index acfd5dba3825..33fba85f6c54 100644 --- a/worlds/kh1/Client.py +++ b/worlds/kh1/Client.py @@ -31,6 +31,9 @@ def check_stdin() -> None: print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.") class KH1ClientCommandProcessor(ClientCommandProcessor): + def __init__(self, ctx): + super().__init__(ctx) + def _cmd_deathlink(self): """Toggles Deathlink""" global death_link @@ -40,6 +43,40 @@ def _cmd_deathlink(self): else: death_link = True self.output(f"Death Link turned on") + + def _cmd_goal(self): + """Prints goal setting""" + if "goal" in self.ctx.slot_data.keys(): + self.output(str(self.ctx.slot_data["goal"])) + else: + self.output("Unknown") + + def _cmd_eotw_unlock(self): + """Prints End of the World Unlock setting""" + if "required_reports_door" in self.ctx.slot_data.keys(): + if self.ctx.slot_data["required_reports_door"] > 13: + self.output("Item") + else: + self.output(str(self.ctx.slot_data["required_reports_eotw"]) + " reports") + else: + self.output("Unknown") + + def _cmd_door_unlock(self): + """Prints Final Rest Door Unlock setting""" + if "door" in self.ctx.slot_data.keys(): + if self.ctx.slot_data["door"] == "reports": + self.output(str(self.ctx.slot_data["required_reports_door"]) + " reports") + else: + self.output(str(self.ctx.slot_data["door"])) + else: + self.output("Unknown") + + def _cmd_advanced_logic(self): + """Prints advanced logic setting""" + if "advanced_logic" in self.ctx.slot_data.keys(): + self.output(str(self.ctx.slot_data["advanced_logic"])) + else: + self.output("Unknown") class KH1Context(CommonContext): command_processor: int = KH1ClientCommandProcessor @@ -51,6 +88,8 @@ def __init__(self, server_address, password): self.send_index: int = 0 self.syncing = False self.awaiting_bridge = False + self.hinted_synth_location_ids = False + self.slot_data = {} # self.game_communication_path: files go in this path to pass data between us and the actual game if "localappdata" in os.environ: self.game_communication_path = os.path.expandvars(r"%localappdata%/KH1FM") @@ -104,6 +143,7 @@ def on_package(self, cmd: str, args: dict): f.close() #Handle Slot Data + self.slot_data = args['slot_data'] for key in list(args['slot_data'].keys()): with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w') as f: f.write(str(args['slot_data'][key])) @@ -217,11 +257,13 @@ async def game_watcher(ctx: KH1Context): if timegm(time.strptime(st, '%Y%m%d%H%M%S')) > ctx.last_death_link and int(time.time()) % int(timegm(time.strptime(st, '%Y%m%d%H%M%S'))) < 10: await ctx.send_death(death_text = "Sora was defeated!") if file.find("insynthshop") > -1: - await ctx.send_msgs([{ - "cmd": "LocationScouts", - "locations": [2656401,2656402,2656403,2656404,2656405,2656406], - "create_as_hint": 2 - }]) + if not ctx.hinted_synth_location_ids: + await ctx.send_msgs([{ + "cmd": "LocationScouts", + "locations": [2656401,2656402,2656403,2656404,2656405,2656406], + "create_as_hint": 2 + }]) + ctx.hinted_synth_location_ids = True ctx.locations_checked = sending message = [{"cmd": 'LocationChecks', "locations": sending}] await ctx.send_msgs(message) diff --git a/worlds/kh2/__init__.py b/worlds/kh2/__init__.py index faf0bed88567..2809460aed6a 100644 --- a/worlds/kh2/__init__.py +++ b/worlds/kh2/__init__.py @@ -101,7 +101,18 @@ def fill_slot_data(self) -> dict: if ability in self.goofy_ability_dict and self.goofy_ability_dict[ability] >= 1: self.goofy_ability_dict[ability] -= 1 - slot_data = self.options.as_dict("Goal", "FinalXemnas", "LuckyEmblemsRequired", "BountyRequired") + slot_data = self.options.as_dict( + "Goal", + "FinalXemnas", + "LuckyEmblemsRequired", + "BountyRequired", + "FightLogic", + "FinalFormLogic", + "AutoFormLogic", + "LevelDepth", + "DonaldGoofyStatsanity", + "CorSkipToggle" + ) slot_data.update({ "hitlist": [], # remove this after next update "PoptrackerVersionCheck": 4.3, diff --git a/worlds/ladx/Items.py b/worlds/ladx/Items.py index 9f4784f74995..1f9358a4f5a6 100644 --- a/worlds/ladx/Items.py +++ b/worlds/ladx/Items.py @@ -83,8 +83,8 @@ class ItemName: RUPEES_200 = "200 Rupees" RUPEES_500 = "500 Rupees" SEASHELL = "Seashell" - MESSAGE = "Master Stalfos' Message" - GEL = "Gel" + MESSAGE = "Nothing" + GEL = "Zol Attack" BOOMERANG = "Boomerang" HEART_PIECE = "Heart Piece" BOWWOW = "BowWow" diff --git a/worlds/ladx/LADXR/patches/goldenLeaf.py b/worlds/ladx/LADXR/patches/goldenLeaf.py index 87cefae0f6d8..b35c722a4316 100644 --- a/worlds/ladx/LADXR/patches/goldenLeaf.py +++ b/worlds/ladx/LADXR/patches/goldenLeaf.py @@ -29,6 +29,7 @@ def fixGoldenLeaf(rom): rom.patch(0x03, 0x0980, ASM("ld a, [$DB15]"), ASM("ld a, [wGoldenLeaves]")) # If leaves >= 6 move richard rom.patch(0x06, 0x0059, ASM("ld a, [$DB15]"), ASM("ld a, [wGoldenLeaves]")) # If leaves >= 6 move richard rom.patch(0x06, 0x007D, ASM("ld a, [$DB15]"), ASM("ld a, [wGoldenLeaves]")) # Richard message if no leaves - rom.patch(0x06, 0x00B8, ASM("ld [$DB15], a"), ASM("ld [wGoldenLeaves], a")) # Stores FF in the leaf counter if we opened the path + rom.patch(0x06, 0x00B6, ASM("ld a, $FF"), ASM("ld a, $06")) + rom.patch(0x06, 0x00B8, ASM("ld [$DB15], a"), ASM("ld [wGoldenLeaves], a")) # Stores 6 in the leaf counter if we opened the path (instead of FF, so that nothing breaks if we get more for some reason) # 6:40EE uses leaves == 6 to check if we have collected the key, but only to change the message. # rom.patch(0x06, 0x2AEF, ASM("ld a, [$DB15]"), ASM("ld a, [wGoldenLeaves]")) # Telephone message handler diff --git a/worlds/ladx/LADXR/patches/owl.py b/worlds/ladx/LADXR/patches/owl.py index 47e575191a31..20b8221604c6 100644 --- a/worlds/ladx/LADXR/patches/owl.py +++ b/worlds/ladx/LADXR/patches/owl.py @@ -81,23 +81,23 @@ def removeOwlEvents(rom): ; Give powder ld a, [$DB4C] - cp $10 + cp $20 jr nc, doNotGivePowder - ld a, $10 + ld a, $20 ld [$DB4C], a doNotGivePowder: ld a, [$DB4D] - cp $10 + cp $30 jr nc, doNotGiveBombs - ld a, $10 + ld a, $30 ld [$DB4D], a doNotGiveBombs: ld a, [$DB45] - cp $10 + cp $30 jr nc, doNotGiveArrows - ld a, $10 + ld a, $30 ld [$DB45], a doNotGiveArrows: diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index 8da297b5c046..822193bb35f8 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -216,7 +216,7 @@ def create_items(self) -> None: for _ in range(count): if item_name in exclude: exclude.remove(item_name) # this is destructive. create unique list above - self.multiworld.itempool.append(self.create_item("Master Stalfos' Message")) + self.multiworld.itempool.append(self.create_item("Nothing")) else: item = self.create_item(item_name) @@ -513,6 +513,9 @@ def remove(self, state, item: Item) -> bool: state.prog_items[self.player]["RUPEES"] -= self.rupees[item.name] return change + def get_filler_item_name(self) -> str: + return "Nothing" + def fill_slot_data(self): slot_data = {} diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md index 8248a4755d3f..a68ee5ba4c7a 100644 --- a/worlds/messenger/docs/en_The Messenger.md +++ b/worlds/messenger/docs/en_The Messenger.md @@ -39,7 +39,9 @@ You can find items wherever items can be picked up in the original game. This in When you attempt to hint for items in Archipelago you can use either the name for the specific item, or the name of a group of items. Hinting for a group will choose a random item from the group that you do not currently have and hint -for it. The groups you can use for The Messenger are: +for it. + +The groups you can use for The Messenger are: * Notes - This covers the music notes * Keys - An alternative name for the music notes * Crest - The Sun and Moon Crests @@ -50,26 +52,26 @@ for it. The groups you can use for The Messenger are: * The player can return to the Tower of Time HQ at any point by selecting the button from the options menu * This can cause issues if used at specific times. If used in any of these known problematic areas, immediately -quit to title and reload the save. The currently known areas include: + quit to title and reload the save. The currently known areas include: * During Boss fights * After Courage Note collection (Corrupted Future chase) * After reaching ninja village a teleport option is added to the menu to reach it quickly * Toggle Windmill Shuriken button is added to option menu once the item is received * The mod option menu will also have a hint item button, as well as a release and collect button that are all placed -when the player fulfills the necessary conditions. + when the player fulfills the necessary conditions. * After running the game with the mod, a config file (APConfig.toml) will be generated in your game folder that can be -used to modify certain settings such as text size and color. This can also be used to specify a player name that can't -be entered in game. + used to modify certain settings such as text size and color. This can also be used to specify a player name that can't + be entered in game. ## Known issues * Ruxxtin Coffin cutscene will sometimes not play correctly, but will still reward the item * If you receive the Magic Firefly while in Quillshroom Marsh, The De-curse Queen cutscene will not play. You can exit -to Searing Crags and re-enter to get it to play correctly. + to Searing Crags and re-enter to get it to play correctly. * Teleporting back to HQ, then returning to the same level you just left through a Portal can cause Ninja to run left -and enter a different portal than the one entered by the player or lead to other incorrect inputs, causing a soft lock + and enter a different portal than the one entered by the player or lead to other incorrect inputs, causing a soft lock * Text entry menus don't accept controller input * In power seal hunt mode, the chest must be opened by entering the shop from a level. Teleporting to HQ and opening the -chest will not work. + chest will not work. ## What do I do if I have a problem? diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md index c1770e747442..64b706c2643a 100644 --- a/worlds/messenger/docs/setup_en.md +++ b/worlds/messenger/docs/setup_en.md @@ -41,14 +41,27 @@ These steps can also be followed to launch the game and check for mod updates af ## Joining a MultiWorld Game +### Automatic Connection on archipelago.gg + +1. Go to the room page of the MultiWorld you are going to join. +2. Click on your slot name on the left side. +3. Click the "The Messenger" button in the prompt. +4. Follow the remaining prompts. This process will check that you have the mod installed and will also check for updates + before launching The Messenger. If you are using the Steam version of The Messenger you may also get a prompt from + Steam asking if the game should be launched with arguments. These arguments are the URI which the mod uses to + connect. +5. Start a new save. You will already be connected in The Messenger and do not need to go through the menus. + +### Manual Connection + 1. Launch the game 2. Navigate to `Options > Archipelago Options` 3. Enter connection info using the relevant option buttons * **The game is limited to alphanumerical characters, `.`, and `-`.** * This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the -website. + website. * If using a name that cannot be entered in the in game menus, there is a config file (APConfig.toml) in the game -directory. When using this, all connection information must be entered in the file. + directory. When using this, all connection information must be entered in the file. 4. Select the `Connect to Archipelago` button 5. Navigate to save file selection 6. Start a new game diff --git a/worlds/osrs/Options.py b/worlds/osrs/Options.py index 520cd8e8b06b..81e017eddb34 100644 --- a/worlds/osrs/Options.py +++ b/worlds/osrs/Options.py @@ -63,6 +63,7 @@ class MaxCombatLevel(Range): The highest combat level of monster to possibly be assigned as a task. If set to 0, no combat tasks will be generated. """ + display_name = "Max Required Enemy Combat Level" range_start = 0 range_end = 1520 default = 50 @@ -74,6 +75,7 @@ class MaxCombatTasks(Range): If set to 0, no combat tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Combat Task Count" range_start = 0 range_end = MAX_COMBAT_TASKS default = MAX_COMBAT_TASKS @@ -85,6 +87,7 @@ class CombatTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Combat Task Weight" range_start = 0 range_end = 99 default = 50 @@ -95,6 +98,7 @@ class MaxPrayerLevel(Range): The highest Prayer requirement of any task generated. If set to 0, no Prayer tasks will be generated. """ + display_name = "Max Required Prayer Level" range_start = 0 range_end = 99 default = 50 @@ -106,6 +110,7 @@ class MaxPrayerTasks(Range): If set to 0, no Prayer tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Prayer Task Count" range_start = 0 range_end = MAX_PRAYER_TASKS default = MAX_PRAYER_TASKS @@ -117,6 +122,7 @@ class PrayerTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Prayer Task Weight" range_start = 0 range_end = 99 default = 50 @@ -127,6 +133,7 @@ class MaxMagicLevel(Range): The highest Magic requirement of any task generated. If set to 0, no Magic tasks will be generated. """ + display_name = "Max Required Magic Level" range_start = 0 range_end = 99 default = 50 @@ -138,6 +145,7 @@ class MaxMagicTasks(Range): If set to 0, no Magic tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Magic Task Count" range_start = 0 range_end = MAX_MAGIC_TASKS default = MAX_MAGIC_TASKS @@ -149,6 +157,7 @@ class MagicTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Magic Task Weight" range_start = 0 range_end = 99 default = 50 @@ -159,6 +168,7 @@ class MaxRunecraftLevel(Range): The highest Runecraft requirement of any task generated. If set to 0, no Runecraft tasks will be generated. """ + display_name = "Max Required Runecraft Level" range_start = 0 range_end = 99 default = 50 @@ -170,6 +180,7 @@ class MaxRunecraftTasks(Range): If set to 0, no Runecraft tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Runecraft Task Count" range_start = 0 range_end = MAX_RUNECRAFT_TASKS default = MAX_RUNECRAFT_TASKS @@ -181,6 +192,7 @@ class RunecraftTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Runecraft Task Weight" range_start = 0 range_end = 99 default = 50 @@ -191,6 +203,7 @@ class MaxCraftingLevel(Range): The highest Crafting requirement of any task generated. If set to 0, no Crafting tasks will be generated. """ + display_name = "Max Required Crafting Level" range_start = 0 range_end = 99 default = 50 @@ -202,6 +215,7 @@ class MaxCraftingTasks(Range): If set to 0, no Crafting tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Crafting Task Count" range_start = 0 range_end = MAX_CRAFTING_TASKS default = MAX_CRAFTING_TASKS @@ -213,6 +227,7 @@ class CraftingTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Crafting Task Weight" range_start = 0 range_end = 99 default = 50 @@ -223,6 +238,7 @@ class MaxMiningLevel(Range): The highest Mining requirement of any task generated. If set to 0, no Mining tasks will be generated. """ + display_name = "Max Required Mining Level" range_start = 0 range_end = 99 default = 50 @@ -234,6 +250,7 @@ class MaxMiningTasks(Range): If set to 0, no Mining tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Mining Task Count" range_start = 0 range_end = MAX_MINING_TASKS default = MAX_MINING_TASKS @@ -245,6 +262,7 @@ class MiningTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Mining Task Weight" range_start = 0 range_end = 99 default = 50 @@ -255,6 +273,7 @@ class MaxSmithingLevel(Range): The highest Smithing requirement of any task generated. If set to 0, no Smithing tasks will be generated. """ + display_name = "Max Required Smithing Level" range_start = 0 range_end = 99 default = 50 @@ -266,6 +285,7 @@ class MaxSmithingTasks(Range): If set to 0, no Smithing tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Smithing Task Count" range_start = 0 range_end = MAX_SMITHING_TASKS default = MAX_SMITHING_TASKS @@ -277,6 +297,7 @@ class SmithingTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Smithing Task Weight" range_start = 0 range_end = 99 default = 50 @@ -287,6 +308,7 @@ class MaxFishingLevel(Range): The highest Fishing requirement of any task generated. If set to 0, no Fishing tasks will be generated. """ + display_name = "Max Required Fishing Level" range_start = 0 range_end = 99 default = 50 @@ -298,6 +320,7 @@ class MaxFishingTasks(Range): If set to 0, no Fishing tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Fishing Task Count" range_start = 0 range_end = MAX_FISHING_TASKS default = MAX_FISHING_TASKS @@ -309,6 +332,7 @@ class FishingTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Fishing Task Weight" range_start = 0 range_end = 99 default = 50 @@ -319,6 +343,7 @@ class MaxCookingLevel(Range): The highest Cooking requirement of any task generated. If set to 0, no Cooking tasks will be generated. """ + display_name = "Max Required Cooking Level" range_start = 0 range_end = 99 default = 50 @@ -330,6 +355,7 @@ class MaxCookingTasks(Range): If set to 0, no Cooking tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Cooking Task Count" range_start = 0 range_end = MAX_COOKING_TASKS default = MAX_COOKING_TASKS @@ -341,6 +367,7 @@ class CookingTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Cooking Task Weight" range_start = 0 range_end = 99 default = 50 @@ -351,6 +378,7 @@ class MaxFiremakingLevel(Range): The highest Firemaking requirement of any task generated. If set to 0, no Firemaking tasks will be generated. """ + display_name = "Max Required Firemaking Level" range_start = 0 range_end = 99 default = 50 @@ -362,6 +390,7 @@ class MaxFiremakingTasks(Range): If set to 0, no Firemaking tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Firemaking Task Count" range_start = 0 range_end = MAX_FIREMAKING_TASKS default = MAX_FIREMAKING_TASKS @@ -373,6 +402,7 @@ class FiremakingTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Firemaking Task Weight" range_start = 0 range_end = 99 default = 50 @@ -383,6 +413,7 @@ class MaxWoodcuttingLevel(Range): The highest Woodcutting requirement of any task generated. If set to 0, no Woodcutting tasks will be generated. """ + display_name = "Max Required Woodcutting Level" range_start = 0 range_end = 99 default = 50 @@ -394,6 +425,7 @@ class MaxWoodcuttingTasks(Range): If set to 0, no Woodcutting tasks will be generated. This only determines the maximum possible, fewer than the maximum could be assigned. """ + display_name = "Max Woodcutting Task Count" range_start = 0 range_end = MAX_WOODCUTTING_TASKS default = MAX_WOODCUTTING_TASKS @@ -405,6 +437,7 @@ class WoodcuttingTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "Woodcutting Task Weight" range_start = 0 range_end = 99 default = 50 @@ -416,6 +449,7 @@ class MinimumGeneralTasks(Range): General progression tasks will be used to fill out any holes caused by having fewer possible tasks than needed, so there is no maximum. """ + display_name = "Minimum General Task Count" range_start = 0 range_end = NON_QUEST_LOCATION_COUNT default = 10 @@ -427,6 +461,7 @@ class GeneralTaskWeight(Range): Weights of all Task Types will be compared against each other, a task with 50 weight is twice as likely to appear as one with 25. """ + display_name = "General Task Weight" range_start = 0 range_end = 99 default = 50 diff --git a/worlds/pokemon_emerald/CHANGELOG.md b/worlds/pokemon_emerald/CHANGELOG.md index 6a1844e79fde..2d7db0dad4d5 100644 --- a/worlds/pokemon_emerald/CHANGELOG.md +++ b/worlds/pokemon_emerald/CHANGELOG.md @@ -8,6 +8,9 @@ ### Fixes +- Fixed a rare issue where receiving a wonder trade could partially corrupt the save data, preventing the player from +receiving new items. +- Fixed the client spamming the "goal complete" status update to the server instead of sending it once. - Fixed a logic issue where the "Mauville City - Coin Case from Lady in House" location only required a Harbor Mail if the player randomized NPC gifts. - The Dig tutor has its compatibility percentage raised to 50% if the player's TM/tutor compatibility is set lower. diff --git a/worlds/pokemon_emerald/client.py b/worlds/pokemon_emerald/client.py index c91b7d3e26b0..4405b34074e0 100644 --- a/worlds/pokemon_emerald/client.py +++ b/worlds/pokemon_emerald/client.py @@ -545,11 +545,12 @@ async def handle_wonder_trade(self, ctx: "BizHawkClientContext", guards: Dict[st if trade_is_sent == 0 and wonder_trade_pokemon_data[19] == 2: # Game has wonder trade data to send. Send it to data storage, remove it from the game's memory, # and mark that the game is waiting on receiving a trade - Utils.async_start(self.wonder_trade_send(ctx, pokemon_data_to_json(wonder_trade_pokemon_data))) - await bizhawk.write(ctx.bizhawk_ctx, [ + success = await bizhawk.guarded_write(ctx.bizhawk_ctx, [ (sb1_address + 0x377C, bytes(0x50), "System Bus"), (sb1_address + 0x37CC, [1], "System Bus"), - ]) + ], [guards["SAVE BLOCK 1"]]) + if success: + Utils.async_start(self.wonder_trade_send(ctx, pokemon_data_to_json(wonder_trade_pokemon_data))) elif trade_is_sent != 0 and wonder_trade_pokemon_data[19] != 2: # Game is waiting on receiving a trade. if self.queued_received_trade is not None: diff --git a/worlds/sc2/ClientGui.py b/worlds/sc2/ClientGui.py index fe62e6162457..51c55b437d92 100644 --- a/worlds/sc2/ClientGui.py +++ b/worlds/sc2/ClientGui.py @@ -111,13 +111,10 @@ def clear_tooltip(self) -> None: def build(self): container = super().build() - panel = TabbedPanelItem(text="Starcraft 2 Launcher") - panel.content = CampaignScroll() + panel = self.add_client_tab("Starcraft 2 Launcher", CampaignScroll()) self.campaign_panel = MultiCampaignLayout() panel.content.add_widget(self.campaign_panel) - self.tabs.add_widget(panel) - Clock.schedule_interval(self.build_mission_table, 0.5) return container diff --git a/worlds/sc2/Items.py b/worlds/sc2/Items.py index 8277d0e7e13d..ee1f34d75be9 100644 --- a/worlds/sc2/Items.py +++ b/worlds/sc2/Items.py @@ -1274,16 +1274,16 @@ def get_full_item_list(): description="Defensive structure. Slows the attack and movement speeds of all nearby Zerg units."), ItemNames.STRUCTURE_ARMOR: ItemData(620 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 9, SC2Race.TERRAN, - description="Increases armor of all Terran structures by 2."), + description="Increases armor of all Terran structures by 2.", origin={"ext"}), ItemNames.HI_SEC_AUTO_TRACKING: ItemData(621 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10, SC2Race.TERRAN, - description="Increases attack range of all Terran structures by 1."), + description="Increases attack range of all Terran structures by 1.", origin={"ext"}), ItemNames.ADVANCED_OPTICS: ItemData(622 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11, SC2Race.TERRAN, - description="Increases attack range of all Terran mechanical units by 1."), + description="Increases attack range of all Terran mechanical units by 1.", origin={"ext"}), ItemNames.ROGUE_FORCES: ItemData(623 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12, SC2Race.TERRAN, - description="Mercenary calldowns are no longer limited by charges."), + description="Mercenary calldowns are no longer limited by charges.", origin={"ext"}), ItemNames.ZEALOT: ItemData(700 + SC2WOL_ITEM_ID_OFFSET, "Unit", 0, SC2Race.PROTOSS, @@ -2369,7 +2369,8 @@ def get_basic_units(world: World, race: SC2Race) -> typing.Set[str]: ItemNames.BATTLECRUISER_PROGRESSIVE_MISSILE_PODS, ItemNames.THOR_PROGRESSIVE_IMMORTALITY_PROTOCOL, ItemNames.PROGRESSIVE_FIRE_SUPPRESSION_SYSTEM, - ItemNames.DIAMONDBACK_PROGRESSIVE_TRI_LITHIUM_POWER_CELL + ItemNames.DIAMONDBACK_PROGRESSIVE_TRI_LITHIUM_POWER_CELL, + ItemNames.PROGRESSIVE_ORBITAL_COMMAND } kerrigan_actives: typing.List[typing.Set[str]] = [ diff --git a/worlds/sm64ex/docs/setup_en.md b/worlds/sm64ex/docs/setup_en.md index 5983057f7d7a..7456bcb70b62 100644 --- a/worlds/sm64ex/docs/setup_en.md +++ b/worlds/sm64ex/docs/setup_en.md @@ -77,9 +77,6 @@ Should your name or password have spaces, enclose it in quotes: `"YourPassword"` Should the connection fail (for example when using the wrong name or IP/Port combination) the game will inform you of that. Additionally, any time the game is not connected (for example when the connection is unstable) it will attempt to reconnect and display a status text. -**Important:** You must start a new file for every new seed you play. Using `⭐x0` files is **not** sufficient. -Failing to use a new file may make some locations unavailable. However, this can be fixed without losing any progress by exiting and starting a new file. - ### Playing offline To play offline, first generate a seed on the game's options page. @@ -129,18 +126,6 @@ To use this batch file, double-click it. A window will open. Type the five-digi Once you provide those two bits of information, the game will open. - If the game only says `Connecting`, try again. Double-check the port number and slot name; even a single typo will cause your connection to fail. -### Addendum - Deleting old saves - -Loading an old Mario save alongside a new seed is a bad idea, as it can cause locked doors and castle secret stars to already be unlocked / obtained. You should avoid opening a save that says "Stars x 0" as opposed to one that simply says "New". - -You can manually delete these old saves in-game before starting a new game, but that can be tedious. With a small edit to the batch files, you can delete these old saves automatically. Just add the line `del %AppData%\sm64ex\*.bin` to the batch file, above the `start` command. For example, here is `offline.bat` with the additional line: - -`del %AppData%\sm64ex\*.bin` - -`start sm64.us.f3dex2e.exe --sm64ap_file %1` - -This extra line deletes any previous save data before opening the game. Don't worry about lost stars or checks - the AP server (or in the case of offline, the `.save` file) keeps track of your star count, unlocked keys/caps/cannons, and which locations have already been checked, so you won't have to redo them. At worst you'll have to rewatch the door unlocking animations, and catch the rabbit Mips twice for his first star again if you haven't yet collected the second one. - ## Installation Troubleshooting Start the game from the command line to view helpful messages regarding SM64EX. @@ -166,8 +151,9 @@ The Japanese Version should have no problem displaying these. ### Toad does not have an item for me. -This happens when you load an existing file that had already received an item from that toad. +This happens on older builds when you load an existing file that had already received an item from that toad. To resolve this, exit and start from a `NEW` file. The server will automatically restore your progress. +Alternatively, updating your build will prevent this issue in the future. ### What happens if I lose connection? diff --git a/worlds/stardew_valley/content/vanilla/pelican_town.py b/worlds/stardew_valley/content/vanilla/pelican_town.py index 73cc8f119a3e..913fe4b8ad96 100644 --- a/worlds/stardew_valley/content/vanilla/pelican_town.py +++ b/worlds/stardew_valley/content/vanilla/pelican_town.py @@ -2,7 +2,7 @@ from ...data import villagers_data, fish_data from ...data.game_item import GenericSource, ItemTag, Tag, CustomRuleSource, CompoundSource from ...data.harvest import ForagingSource, SeasonalForagingSource, ArtifactSpotSource -from ...data.requirement import ToolRequirement, BookRequirement, SkillRequirement, SeasonRequirement +from ...data.requirement import ToolRequirement, BookRequirement, SkillRequirement from ...data.shop import ShopSource, MysteryBoxSource, ArtifactTroveSource, PrizeMachineSource, FishingTreasureChestSource from ...strings.book_names import Book from ...strings.crop_names import Fruit @@ -250,10 +250,7 @@ ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),), Book.the_art_o_crabbing: ( Tag(ItemTag.BOOK, ItemTag.BOOK_POWER), - GenericSource(regions=(Region.beach,), - other_requirements=(ToolRequirement(Tool.fishing_rod, ToolMaterial.iridium), - SkillRequirement(Skill.fishing, 6), - SeasonRequirement(Season.winter))), + CustomRuleSource(create_rule=lambda logic: logic.festival.has_squidfest_day_1_iridium_reward()), ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),), Book.treasure_appraisal_guide: ( Tag(ItemTag.BOOK, ItemTag.BOOK_POWER), diff --git a/worlds/stardew_valley/data/items.csv b/worlds/stardew_valley/data/items.csv index e026090f8659..ffcae223e251 100644 --- a/worlds/stardew_valley/data/items.csv +++ b/worlds/stardew_valley/data/items.csv @@ -474,7 +474,7 @@ id,name,classification,groups,mod_name 507,Resource Pack: 40 Calico Egg,useful,"FESTIVAL", 508,Resource Pack: 35 Calico Egg,useful,"FESTIVAL", 509,Resource Pack: 30 Calico Egg,useful,"FESTIVAL", -510,Book: The Art O' Crabbing,useful,"FESTIVAL", +510,Book: The Art O' Crabbing,progression,"FESTIVAL", 511,Mr Qi's Plane Ride,progression,, 521,Power: Price Catalogue,useful,"BOOK_POWER", 522,Power: Mapping Cave Systems,useful,"BOOK_POWER", @@ -819,6 +819,7 @@ id,name,classification,groups,mod_name 5289,Prismatic Shard,filler,"RESOURCE_PACK", 5290,Stardrop Tea,filler,"RESOURCE_PACK", 5291,Resource Pack: 2 Artifact Trove,filler,"RESOURCE_PACK", +5292,Resource Pack: 20 Cinder Shard,filler,"GINGER_ISLAND,RESOURCE_PACK", 10001,Luck Level,progression,SKILL_LEVEL_UP,Luck Skill 10002,Magic Level,progression,SKILL_LEVEL_UP,Magic 10003,Socializing Level,progression,SKILL_LEVEL_UP,Socializing Skill diff --git a/worlds/stardew_valley/data/locations.csv b/worlds/stardew_valley/data/locations.csv index 608b6a5f576a..680ddfcbacbf 100644 --- a/worlds/stardew_valley/data/locations.csv +++ b/worlds/stardew_valley/data/locations.csv @@ -313,14 +313,14 @@ id,region,name,tags,mod_name 611,JotPK World 2,JotPK: Cowboy 2,"ARCADE_MACHINE,JOTPK", 612,Junimo Kart 1,Junimo Kart: Crumble Cavern,"ARCADE_MACHINE,JUNIMO_KART", 613,Junimo Kart 1,Junimo Kart: Slippery Slopes,"ARCADE_MACHINE,JUNIMO_KART", -614,Junimo Kart 2,Junimo Kart: Secret Level,"ARCADE_MACHINE,JUNIMO_KART", +614,Junimo Kart 4,Junimo Kart: Secret Level,"ARCADE_MACHINE,JUNIMO_KART", 615,Junimo Kart 2,Junimo Kart: The Gem Sea Giant,"ARCADE_MACHINE,JUNIMO_KART", 616,Junimo Kart 2,Junimo Kart: Slomp's Stomp,"ARCADE_MACHINE,JUNIMO_KART", 617,Junimo Kart 2,Junimo Kart: Ghastly Galleon,"ARCADE_MACHINE,JUNIMO_KART", 618,Junimo Kart 3,Junimo Kart: Glowshroom Grotto,"ARCADE_MACHINE,JUNIMO_KART", 619,Junimo Kart 3,Junimo Kart: Red Hot Rollercoaster,"ARCADE_MACHINE,JUNIMO_KART", 620,JotPK World 3,Journey of the Prairie King Victory,"ARCADE_MACHINE_VICTORY,JOTPK", -621,Junimo Kart 3,Junimo Kart: Sunset Speedway (Victory),"ARCADE_MACHINE_VICTORY,JUNIMO_KART", +621,Junimo Kart 4,Junimo Kart: Sunset Speedway (Victory),"ARCADE_MACHINE_VICTORY,JUNIMO_KART", 701,Secret Woods,Old Master Cannoli,MANDATORY, 702,Beach,Beach Bridge Repair,MANDATORY, 703,Desert,Galaxy Sword Shrine,MANDATORY, diff --git a/worlds/stardew_valley/logic/festival_logic.py b/worlds/stardew_valley/logic/festival_logic.py new file mode 100644 index 000000000000..2b22617202d8 --- /dev/null +++ b/worlds/stardew_valley/logic/festival_logic.py @@ -0,0 +1,186 @@ +from typing import Union + +from .action_logic import ActionLogicMixin +from .animal_logic import AnimalLogicMixin +from .artisan_logic import ArtisanLogicMixin +from .base_logic import BaseLogicMixin, BaseLogic +from .fishing_logic import FishingLogicMixin +from .gift_logic import GiftLogicMixin +from .has_logic import HasLogicMixin +from .money_logic import MoneyLogicMixin +from .monster_logic import MonsterLogicMixin +from .museum_logic import MuseumLogicMixin +from .received_logic import ReceivedLogicMixin +from .region_logic import RegionLogicMixin +from .relationship_logic import RelationshipLogicMixin +from .skill_logic import SkillLogicMixin +from .time_logic import TimeLogicMixin +from ..options import FestivalLocations +from ..stardew_rule import StardewRule +from ..strings.book_names import Book +from ..strings.craftable_names import Fishing +from ..strings.crop_names import Fruit, Vegetable +from ..strings.festival_check_names import FestivalCheck +from ..strings.fish_names import Fish +from ..strings.forageable_names import Forageable +from ..strings.generic_names import Generic +from ..strings.machine_names import Machine +from ..strings.monster_names import Monster +from ..strings.region_names import Region + + +class FestivalLogicMixin(BaseLogicMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.festival = FestivalLogic(*args, **kwargs) + + +class FestivalLogic(BaseLogic[Union[HasLogicMixin, ReceivedLogicMixin, FestivalLogicMixin, ArtisanLogicMixin, AnimalLogicMixin, MoneyLogicMixin, TimeLogicMixin, +SkillLogicMixin, RegionLogicMixin, ActionLogicMixin, MonsterLogicMixin, RelationshipLogicMixin, FishingLogicMixin, MuseumLogicMixin, GiftLogicMixin]]): + + def initialize_rules(self): + self.registry.festival_rules.update({ + FestivalCheck.egg_hunt: self.logic.festival.can_win_egg_hunt(), + FestivalCheck.strawberry_seeds: self.logic.money.can_spend(1000), + FestivalCheck.dance: self.logic.relationship.has_hearts_with_any_bachelor(4), + FestivalCheck.tub_o_flowers: self.logic.money.can_spend(2000), + FestivalCheck.rarecrow_5: self.logic.money.can_spend(2500), + FestivalCheck.luau_soup: self.logic.festival.can_succeed_luau_soup(), + FestivalCheck.moonlight_jellies: self.logic.true_, + FestivalCheck.moonlight_jellies_banner: self.logic.money.can_spend(800), + FestivalCheck.starport_decal: self.logic.money.can_spend(1000), + FestivalCheck.smashing_stone: self.logic.true_, + FestivalCheck.grange_display: self.logic.festival.can_succeed_grange_display(), + FestivalCheck.rarecrow_1: self.logic.true_, # only cost star tokens + FestivalCheck.fair_stardrop: self.logic.true_, # only cost star tokens + FestivalCheck.spirit_eve_maze: self.logic.true_, + FestivalCheck.jack_o_lantern: self.logic.money.can_spend(2000), + FestivalCheck.rarecrow_2: self.logic.money.can_spend(5000), + FestivalCheck.fishing_competition: self.logic.festival.can_win_fishing_competition(), + FestivalCheck.rarecrow_4: self.logic.money.can_spend(5000), + FestivalCheck.mermaid_pearl: self.logic.has(Forageable.secret_note), + FestivalCheck.cone_hat: self.logic.money.can_spend(2500), + FestivalCheck.iridium_fireplace: self.logic.money.can_spend(15000), + FestivalCheck.rarecrow_7: self.logic.money.can_spend(5000) & self.logic.museum.can_donate_museum_artifacts(20), + FestivalCheck.rarecrow_8: self.logic.money.can_spend(5000) & self.logic.museum.can_donate_museum_items(40), + FestivalCheck.lupini_red_eagle: self.logic.money.can_spend(1200), + FestivalCheck.lupini_portrait_mermaid: self.logic.money.can_spend(1200), + FestivalCheck.lupini_solar_kingdom: self.logic.money.can_spend(1200), + FestivalCheck.lupini_clouds: self.logic.time.has_year_two & self.logic.money.can_spend(1200), + FestivalCheck.lupini_1000_years: self.logic.time.has_year_two & self.logic.money.can_spend(1200), + FestivalCheck.lupini_three_trees: self.logic.time.has_year_two & self.logic.money.can_spend(1200), + FestivalCheck.lupini_the_serpent: self.logic.time.has_year_three & self.logic.money.can_spend(1200), + FestivalCheck.lupini_tropical_fish: self.logic.time.has_year_three & self.logic.money.can_spend(1200), + FestivalCheck.lupini_land_of_clay: self.logic.time.has_year_three & self.logic.money.can_spend(1200), + FestivalCheck.secret_santa: self.logic.gifts.has_any_universal_love, + FestivalCheck.legend_of_the_winter_star: self.logic.true_, + FestivalCheck.rarecrow_3: self.logic.true_, + FestivalCheck.all_rarecrows: self.logic.region.can_reach(Region.farm) & self.logic.festival.has_all_rarecrows(), + FestivalCheck.calico_race: self.logic.true_, + FestivalCheck.mummy_mask: self.logic.true_, + FestivalCheck.calico_statue: self.logic.true_, + FestivalCheck.emily_outfit_service: self.logic.true_, + FestivalCheck.earthy_mousse: self.logic.true_, + FestivalCheck.sweet_bean_cake: self.logic.true_, + FestivalCheck.skull_cave_casserole: self.logic.true_, + FestivalCheck.spicy_tacos: self.logic.true_, + FestivalCheck.mountain_chili: self.logic.true_, + FestivalCheck.crystal_cake: self.logic.true_, + FestivalCheck.cave_kebab: self.logic.true_, + FestivalCheck.hot_log: self.logic.true_, + FestivalCheck.sour_salad: self.logic.true_, + FestivalCheck.superfood_cake: self.logic.true_, + FestivalCheck.warrior_smoothie: self.logic.true_, + FestivalCheck.rumpled_fruit_skin: self.logic.true_, + FestivalCheck.calico_pizza: self.logic.true_, + FestivalCheck.stuffed_mushrooms: self.logic.true_, + FestivalCheck.elf_quesadilla: self.logic.true_, + FestivalCheck.nachos_of_the_desert: self.logic.true_, + FestivalCheck.cloppino: self.logic.true_, + FestivalCheck.rainforest_shrimp: self.logic.true_, + FestivalCheck.shrimp_donut: self.logic.true_, + FestivalCheck.smell_of_the_sea: self.logic.true_, + FestivalCheck.desert_gumbo: self.logic.true_, + FestivalCheck.free_cactis: self.logic.true_, + FestivalCheck.monster_hunt: self.logic.monster.can_kill(Monster.serpent), + FestivalCheck.deep_dive: self.logic.region.can_reach(Region.skull_cavern_50), + FestivalCheck.treasure_hunt: self.logic.region.can_reach(Region.skull_cavern_25), + FestivalCheck.touch_calico_statue: self.logic.region.can_reach(Region.skull_cavern_25), + FestivalCheck.real_calico_egg_hunter: self.logic.region.can_reach(Region.skull_cavern_100), + FestivalCheck.willy_challenge: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.scorpion_carp]), + FestivalCheck.desert_scholar: self.logic.true_, + FestivalCheck.squidfest_day_1_copper: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]), + FestivalCheck.squidfest_day_1_iron: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]) & self.logic.has(Fishing.bait), + FestivalCheck.squidfest_day_1_gold: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]) & self.logic.has(Fishing.deluxe_bait), + FestivalCheck.squidfest_day_1_iridium: self.logic.festival.can_squidfest_day_1_iridium_reward(), + FestivalCheck.squidfest_day_2_copper: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]), + FestivalCheck.squidfest_day_2_iron: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]) & self.logic.has(Fishing.bait), + FestivalCheck.squidfest_day_2_gold: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]) & self.logic.has(Fishing.deluxe_bait), + FestivalCheck.squidfest_day_2_iridium: self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]) & + self.logic.fishing.has_specific_bait(self.content.fishes[Fish.squid]), + }) + for i in range(1, 11): + check_name = f"{FestivalCheck.trout_derby_reward_pattern}{i}" + self.registry.festival_rules[check_name] = self.logic.fishing.can_catch_fish(self.content.fishes[Fish.rainbow_trout]) + + def can_squidfest_day_1_iridium_reward(self) -> StardewRule: + return self.logic.fishing.can_catch_fish(self.content.fishes[Fish.squid]) & self.logic.fishing.has_specific_bait(self.content.fishes[Fish.squid]) + + def has_squidfest_day_1_iridium_reward(self) -> StardewRule: + if self.options.festival_locations == FestivalLocations.option_disabled: + return self.logic.festival.can_squidfest_day_1_iridium_reward() + else: + return self.logic.received(f"Book: {Book.the_art_o_crabbing}") + + def can_win_egg_hunt(self) -> StardewRule: + return self.logic.true_ + + def can_succeed_luau_soup(self) -> StardewRule: + if self.options.festival_locations != FestivalLocations.option_hard: + return self.logic.true_ + eligible_fish = (Fish.blobfish, Fish.crimsonfish, Fish.ice_pip, Fish.lava_eel, Fish.legend, Fish.angler, Fish.catfish, Fish.glacierfish, + Fish.mutant_carp, Fish.spookfish, Fish.stingray, Fish.sturgeon, Fish.super_cucumber) + fish_rule = self.logic.has_any(*(f for f in eligible_fish if f in self.content.fishes)) # To filter stingray + eligible_kegables = (Fruit.ancient_fruit, Fruit.apple, Fruit.banana, Forageable.coconut, Forageable.crystal_fruit, Fruit.mango, Fruit.melon, + Fruit.orange, Fruit.peach, Fruit.pineapple, Fruit.pomegranate, Fruit.rhubarb, Fruit.starfruit, Fruit.strawberry, + Forageable.cactus_fruit, Fruit.cherry, Fruit.cranberries, Fruit.grape, Forageable.spice_berry, Forageable.wild_plum, + Vegetable.hops, Vegetable.wheat) + keg_rules = [self.logic.artisan.can_keg(kegable) for kegable in eligible_kegables if kegable in self.content.game_items] + aged_rule = self.logic.has(Machine.cask) & self.logic.or_(*keg_rules) + # There are a few other valid items, but I don't feel like coding them all + return fish_rule | aged_rule + + def can_succeed_grange_display(self) -> StardewRule: + if self.options.festival_locations != FestivalLocations.option_hard: + return self.logic.true_ + + animal_rule = self.logic.animal.has_animal(Generic.any) + artisan_rule = self.logic.artisan.can_keg(Generic.any) | self.logic.artisan.can_preserves_jar(Generic.any) + cooking_rule = self.logic.money.can_spend_at(Region.saloon, 220) # Salads at the bar are good enough + fish_rule = self.logic.skill.can_fish(difficulty=50) + forage_rule = self.logic.region.can_reach_any((Region.forest, Region.backwoods)) # Hazelnut always available since the grange display is in fall + mineral_rule = self.logic.action.can_open_geode(Generic.any) # More than half the minerals are good enough + good_fruits = (fruit + for fruit in + (Fruit.apple, Fruit.banana, Forageable.coconut, Forageable.crystal_fruit, Fruit.mango, Fruit.orange, Fruit.peach, Fruit.pomegranate, + Fruit.strawberry, Fruit.melon, Fruit.rhubarb, Fruit.pineapple, Fruit.ancient_fruit, Fruit.starfruit) + if fruit in self.content.game_items) + fruit_rule = self.logic.has_any(*good_fruits) + good_vegetables = (vegeteable + for vegeteable in + (Vegetable.amaranth, Vegetable.artichoke, Vegetable.beet, Vegetable.cauliflower, Forageable.fiddlehead_fern, Vegetable.kale, + Vegetable.radish, Vegetable.taro_root, Vegetable.yam, Vegetable.red_cabbage, Vegetable.pumpkin) + if vegeteable in self.content.game_items) + vegetable_rule = self.logic.has_any(*good_vegetables) + + return animal_rule & artisan_rule & cooking_rule & fish_rule & \ + forage_rule & fruit_rule & mineral_rule & vegetable_rule + + def can_win_fishing_competition(self) -> StardewRule: + return self.logic.skill.can_fish(difficulty=60) + + def has_all_rarecrows(self) -> StardewRule: + rules = [] + for rarecrow_number in range(1, 9): + rules.append(self.logic.received(f"Rarecrow #{rarecrow_number}")) + return self.logic.and_(*rules) diff --git a/worlds/stardew_valley/logic/logic.py b/worlds/stardew_valley/logic/logic.py index fb0d938fbb1e..9d4447439f7b 100644 --- a/worlds/stardew_valley/logic/logic.py +++ b/worlds/stardew_valley/logic/logic.py @@ -16,6 +16,7 @@ from .cooking_logic import CookingLogicMixin from .crafting_logic import CraftingLogicMixin from .farming_logic import FarmingLogicMixin +from .festival_logic import FestivalLogicMixin from .fishing_logic import FishingLogicMixin from .gift_logic import GiftLogicMixin from .grind_logic import GrindLogicMixin @@ -62,7 +63,6 @@ from ..strings.currency_names import Currency from ..strings.decoration_names import Decoration from ..strings.fertilizer_names import Fertilizer, SpeedGro, RetainingSoil -from ..strings.festival_check_names import FestivalCheck from ..strings.fish_names import Fish, Trash, WaterItem, WaterChest from ..strings.flower_names import Flower from ..strings.food_names import Meal, Beverage @@ -94,7 +94,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin CombatLogicMixin, MagicLogicMixin, MonsterLogicMixin, ToolLogicMixin, PetLogicMixin, QualityLogicMixin, SkillLogicMixin, FarmingLogicMixin, BundleLogicMixin, FishingLogicMixin, MineLogicMixin, CookingLogicMixin, AbilityLogicMixin, SpecialOrderLogicMixin, QuestLogicMixin, CraftingLogicMixin, ModLogicMixin, HarvestingLogicMixin, SourceLogicMixin, - RequirementLogicMixin, BookLogicMixin, GrindLogicMixin, WalnutLogicMixin): + RequirementLogicMixin, BookLogicMixin, GrindLogicMixin, FestivalLogicMixin, WalnutLogicMixin): player: int options: StardewValleyOptions content: StardewContent @@ -363,89 +363,7 @@ def __init__(self, player: int, options: StardewValleyOptions, content: StardewC self.quest.initialize_rules() self.quest.update_rules(self.mod.quest.get_modded_quest_rules()) - self.registry.festival_rules.update({ - FestivalCheck.egg_hunt: self.can_win_egg_hunt(), - FestivalCheck.strawberry_seeds: self.money.can_spend(1000), - FestivalCheck.dance: self.relationship.has_hearts_with_any_bachelor(4), - FestivalCheck.tub_o_flowers: self.money.can_spend(2000), - FestivalCheck.rarecrow_5: self.money.can_spend(2500), - FestivalCheck.luau_soup: self.can_succeed_luau_soup(), - FestivalCheck.moonlight_jellies: True_(), - FestivalCheck.moonlight_jellies_banner: self.money.can_spend(800), - FestivalCheck.starport_decal: self.money.can_spend(1000), - FestivalCheck.smashing_stone: True_(), - FestivalCheck.grange_display: self.can_succeed_grange_display(), - FestivalCheck.rarecrow_1: True_(), # only cost star tokens - FestivalCheck.fair_stardrop: True_(), # only cost star tokens - FestivalCheck.spirit_eve_maze: True_(), - FestivalCheck.jack_o_lantern: self.money.can_spend(2000), - FestivalCheck.rarecrow_2: self.money.can_spend(5000), - FestivalCheck.fishing_competition: self.can_win_fishing_competition(), - FestivalCheck.rarecrow_4: self.money.can_spend(5000), - FestivalCheck.mermaid_pearl: self.has(Forageable.secret_note), - FestivalCheck.cone_hat: self.money.can_spend(2500), - FestivalCheck.iridium_fireplace: self.money.can_spend(15000), - FestivalCheck.rarecrow_7: self.money.can_spend(5000) & self.museum.can_donate_museum_artifacts(20), - FestivalCheck.rarecrow_8: self.money.can_spend(5000) & self.museum.can_donate_museum_items(40), - FestivalCheck.lupini_red_eagle: self.money.can_spend(1200), - FestivalCheck.lupini_portrait_mermaid: self.money.can_spend(1200), - FestivalCheck.lupini_solar_kingdom: self.money.can_spend(1200), - FestivalCheck.lupini_clouds: self.time.has_year_two & self.money.can_spend(1200), - FestivalCheck.lupini_1000_years: self.time.has_year_two & self.money.can_spend(1200), - FestivalCheck.lupini_three_trees: self.time.has_year_two & self.money.can_spend(1200), - FestivalCheck.lupini_the_serpent: self.time.has_year_three & self.money.can_spend(1200), - FestivalCheck.lupini_tropical_fish: self.time.has_year_three & self.money.can_spend(1200), - FestivalCheck.lupini_land_of_clay: self.time.has_year_three & self.money.can_spend(1200), - FestivalCheck.secret_santa: self.gifts.has_any_universal_love, - FestivalCheck.legend_of_the_winter_star: True_(), - FestivalCheck.rarecrow_3: True_(), - FestivalCheck.all_rarecrows: self.region.can_reach(Region.farm) & self.has_all_rarecrows(), - FestivalCheck.calico_race: True_(), - FestivalCheck.mummy_mask: True_(), - FestivalCheck.calico_statue: True_(), - FestivalCheck.emily_outfit_service: True_(), - FestivalCheck.earthy_mousse: True_(), - FestivalCheck.sweet_bean_cake: True_(), - FestivalCheck.skull_cave_casserole: True_(), - FestivalCheck.spicy_tacos: True_(), - FestivalCheck.mountain_chili: True_(), - FestivalCheck.crystal_cake: True_(), - FestivalCheck.cave_kebab: True_(), - FestivalCheck.hot_log: True_(), - FestivalCheck.sour_salad: True_(), - FestivalCheck.superfood_cake: True_(), - FestivalCheck.warrior_smoothie: True_(), - FestivalCheck.rumpled_fruit_skin: True_(), - FestivalCheck.calico_pizza: True_(), - FestivalCheck.stuffed_mushrooms: True_(), - FestivalCheck.elf_quesadilla: True_(), - FestivalCheck.nachos_of_the_desert: True_(), - FestivalCheck.cloppino: True_(), - FestivalCheck.rainforest_shrimp: True_(), - FestivalCheck.shrimp_donut: True_(), - FestivalCheck.smell_of_the_sea: True_(), - FestivalCheck.desert_gumbo: True_(), - FestivalCheck.free_cactis: True_(), - FestivalCheck.monster_hunt: self.monster.can_kill(Monster.serpent), - FestivalCheck.deep_dive: self.region.can_reach(Region.skull_cavern_50), - FestivalCheck.treasure_hunt: self.region.can_reach(Region.skull_cavern_25), - FestivalCheck.touch_calico_statue: self.region.can_reach(Region.skull_cavern_25), - FestivalCheck.real_calico_egg_hunter: self.region.can_reach(Region.skull_cavern_100), - FestivalCheck.willy_challenge: self.fishing.can_catch_fish(content.fishes[Fish.scorpion_carp]), - FestivalCheck.desert_scholar: True_(), - FestivalCheck.squidfest_day_1_copper: self.fishing.can_catch_fish(content.fishes[Fish.squid]), - FestivalCheck.squidfest_day_1_iron: self.fishing.can_catch_fish(content.fishes[Fish.squid]) & self.has(Fishing.bait), - FestivalCheck.squidfest_day_1_gold: self.fishing.can_catch_fish(content.fishes[Fish.squid]) & self.has(Fishing.deluxe_bait), - FestivalCheck.squidfest_day_1_iridium: self.fishing.can_catch_fish(content.fishes[Fish.squid]) & - self.fishing.has_specific_bait(content.fishes[Fish.squid]), - FestivalCheck.squidfest_day_2_copper: self.fishing.can_catch_fish(content.fishes[Fish.squid]), - FestivalCheck.squidfest_day_2_iron: self.fishing.can_catch_fish(content.fishes[Fish.squid]) & self.has(Fishing.bait), - FestivalCheck.squidfest_day_2_gold: self.fishing.can_catch_fish(content.fishes[Fish.squid]) & self.has(Fishing.deluxe_bait), - FestivalCheck.squidfest_day_2_iridium: self.fishing.can_catch_fish(content.fishes[Fish.squid]) & - self.fishing.has_specific_bait(content.fishes[Fish.squid]), - }) - for i in range(1, 11): - self.registry.festival_rules[f"{FestivalCheck.trout_derby_reward_pattern}{i}"] = self.fishing.can_catch_fish(content.fishes[Fish.rainbow_trout]) + self.festival.initialize_rules() self.special_order.initialize_rules() self.special_order.update_rules(self.mod.special_order.get_modded_special_orders_rules()) @@ -486,53 +404,6 @@ def can_finish_grandpa_evaluation(self) -> StardewRule: ] return self.count(12, *rules_worth_a_point) - def can_win_egg_hunt(self) -> StardewRule: - return True_() - - def can_succeed_luau_soup(self) -> StardewRule: - if self.options.festival_locations != FestivalLocations.option_hard: - return True_() - eligible_fish = (Fish.blobfish, Fish.crimsonfish, Fish.ice_pip, Fish.lava_eel, Fish.legend, Fish.angler, Fish.catfish, Fish.glacierfish, - Fish.mutant_carp, Fish.spookfish, Fish.stingray, Fish.sturgeon, Fish.super_cucumber) - fish_rule = self.has_any(*(f for f in eligible_fish if f in self.content.fishes)) # To filter stingray - eligible_kegables = (Fruit.ancient_fruit, Fruit.apple, Fruit.banana, Forageable.coconut, Forageable.crystal_fruit, Fruit.mango, Fruit.melon, - Fruit.orange, Fruit.peach, Fruit.pineapple, Fruit.pomegranate, Fruit.rhubarb, Fruit.starfruit, Fruit.strawberry, - Forageable.cactus_fruit, Fruit.cherry, Fruit.cranberries, Fruit.grape, Forageable.spice_berry, Forageable.wild_plum, - Vegetable.hops, Vegetable.wheat) - keg_rules = [self.artisan.can_keg(kegable) for kegable in eligible_kegables if kegable in self.content.game_items] - aged_rule = self.has(Machine.cask) & self.logic.or_(*keg_rules) - # There are a few other valid items, but I don't feel like coding them all - return fish_rule | aged_rule - - def can_succeed_grange_display(self) -> StardewRule: - if self.options.festival_locations != FestivalLocations.option_hard: - return True_() - - animal_rule = self.animal.has_animal(Generic.any) - artisan_rule = self.artisan.can_keg(Generic.any) | self.artisan.can_preserves_jar(Generic.any) - cooking_rule = self.money.can_spend_at(Region.saloon, 220) # Salads at the bar are good enough - fish_rule = self.skill.can_fish(difficulty=50) - forage_rule = self.region.can_reach_any((Region.forest, Region.backwoods)) # Hazelnut always available since the grange display is in fall - mineral_rule = self.action.can_open_geode(Generic.any) # More than half the minerals are good enough - good_fruits = (fruit - for fruit in - (Fruit.apple, Fruit.banana, Forageable.coconut, Forageable.crystal_fruit, Fruit.mango, Fruit.orange, Fruit.peach, Fruit.pomegranate, - Fruit.strawberry, Fruit.melon, Fruit.rhubarb, Fruit.pineapple, Fruit.ancient_fruit, Fruit.starfruit) - if fruit in self.content.game_items) - fruit_rule = self.has_any(*good_fruits) - good_vegetables = (vegeteable - for vegeteable in - (Vegetable.amaranth, Vegetable.artichoke, Vegetable.beet, Vegetable.cauliflower, Forageable.fiddlehead_fern, Vegetable.kale, - Vegetable.radish, Vegetable.taro_root, Vegetable.yam, Vegetable.red_cabbage, Vegetable.pumpkin) - if vegeteable in self.content.game_items) - vegetable_rule = self.has_any(*good_vegetables) - - return animal_rule & artisan_rule & cooking_rule & fish_rule & \ - forage_rule & fruit_rule & mineral_rule & vegetable_rule - - def can_win_fishing_competition(self) -> StardewRule: - return self.skill.can_fish(difficulty=60) - def has_island_trader(self) -> StardewRule: if self.options.exclude_ginger_island == ExcludeGingerIsland.option_true: return False_() @@ -571,12 +442,6 @@ def has_all_stardrops(self) -> StardewRule: return self.received("Stardrop", number_of_stardrops_to_receive) & self.logic.and_(*other_rules) - def has_all_rarecrows(self) -> StardewRule: - rules = [] - for rarecrow_number in range(1, 9): - rules.append(self.received(f"Rarecrow #{rarecrow_number}")) - return self.logic.and_(*rules) - def has_abandoned_jojamart(self) -> StardewRule: return self.received(CommunityUpgrade.movie_theater, 1) diff --git a/worlds/stardew_valley/regions.py b/worlds/stardew_valley/regions.py index b0fc7fa0ea52..5b7db5ac79d1 100644 --- a/worlds/stardew_valley/regions.py +++ b/worlds/stardew_valley/regions.py @@ -87,7 +87,8 @@ def __call__(self, name: str, regions: Iterable[str]) -> Region: RegionData(Region.jotpk_world_3), RegionData(Region.junimo_kart_1, [Entrance.reach_junimo_kart_2]), RegionData(Region.junimo_kart_2, [Entrance.reach_junimo_kart_3]), - RegionData(Region.junimo_kart_3), + RegionData(Region.junimo_kart_3, [Entrance.reach_junimo_kart_4]), + RegionData(Region.junimo_kart_4), RegionData(Region.alex_house), RegionData(Region.trailer), RegionData(Region.mayor_house), @@ -330,6 +331,7 @@ def __call__(self, name: str, regions: Iterable[str]) -> Region: ConnectionData(Entrance.play_junimo_kart, Region.junimo_kart_1), ConnectionData(Entrance.reach_junimo_kart_2, Region.junimo_kart_2), ConnectionData(Entrance.reach_junimo_kart_3, Region.junimo_kart_3), + ConnectionData(Entrance.reach_junimo_kart_4, Region.junimo_kart_4), ConnectionData(Entrance.town_to_sam_house, Region.sam_house, flag=RandomizationFlag.PELICAN_TOWN | RandomizationFlag.LEAD_TO_OPEN_AREA), ConnectionData(Entrance.town_to_haley_house, Region.haley_house, diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py index 7f39ee1ac2d4..eda2d4377e09 100644 --- a/worlds/stardew_valley/rules.py +++ b/worlds/stardew_valley/rules.py @@ -891,7 +891,7 @@ def set_arcade_machine_rules(logic: StardewLogic, multiworld: MultiWorld, player logic.has("Junimo Kart Medium Buff")) MultiWorldRules.add_rule(multiworld.get_entrance(Entrance.reach_junimo_kart_3, player), logic.has("Junimo Kart Big Buff")) - MultiWorldRules.add_rule(multiworld.get_location("Junimo Kart: Sunset Speedway (Victory)", player), + MultiWorldRules.add_rule(multiworld.get_entrance(Entrance.reach_junimo_kart_4, player), logic.has("Junimo Kart Max Buff")) MultiWorldRules.add_rule(multiworld.get_entrance(Entrance.play_journey_of_the_prairie_king, player), logic.has("JotPK Small Buff")) diff --git a/worlds/stardew_valley/strings/entrance_names.py b/worlds/stardew_valley/strings/entrance_names.py index 58a919f2a8a4..b1c84004eb7a 100644 --- a/worlds/stardew_valley/strings/entrance_names.py +++ b/worlds/stardew_valley/strings/entrance_names.py @@ -94,6 +94,7 @@ class Entrance: play_junimo_kart = "Play Junimo Kart" reach_junimo_kart_2 = "Reach Junimo Kart 2" reach_junimo_kart_3 = "Reach Junimo Kart 3" + reach_junimo_kart_4 = "Reach Junimo Kart 4" enter_locker_room = "Bathhouse Entrance to Locker Room" enter_public_bath = "Locker Room to Public Bath" enter_witch_swamp = "Witch Warp Cave to Witch's Swamp" diff --git a/worlds/stardew_valley/strings/region_names.py b/worlds/stardew_valley/strings/region_names.py index 58763b6fcb80..2bbc6228ab19 100644 --- a/worlds/stardew_valley/strings/region_names.py +++ b/worlds/stardew_valley/strings/region_names.py @@ -114,6 +114,7 @@ class Region: junimo_kart_1 = "Junimo Kart 1" junimo_kart_2 = "Junimo Kart 2" junimo_kart_3 = "Junimo Kart 3" + junimo_kart_4 = "Junimo Kart 4" mines_floor_5 = "The Mines - Floor 5" mines_floor_10 = "The Mines - Floor 10" mines_floor_15 = "The Mines - Floor 15" diff --git a/worlds/timespinner/Locations.py b/worlds/timespinner/Locations.py index f99dd7615571..2423e06bb010 100644 --- a/worlds/timespinner/Locations.py +++ b/worlds/timespinner/Locations.py @@ -207,7 +207,7 @@ def get_location_datas(player: Optional[int], options: Optional[TimespinnerOptio LocationData('Library', 'Library: Terminal 2 (Lachiem)', 1337156, lambda state: state.has('Tablet', player)), LocationData('Library', 'Library: Terminal 1 (Windaria)', 1337157, lambda state: state.has('Tablet', player)), # 1337158 Is lost in time - LocationData('Library', 'Library: Terminal 3 (Emporer Nuvius)', 1337159, lambda state: state.has('Tablet', player)), + LocationData('Library', 'Library: Terminal 3 (Emperor Nuvius)', 1337159, lambda state: state.has('Tablet', player)), LocationData('Library', 'Library: V terminal 1 (War of the Sisters)', 1337160, lambda state: state.has_all({'Tablet', 'Library Keycard V'}, player)), LocationData('Library', 'Library: V terminal 2 (Lake Desolation Map)', 1337161, lambda state: state.has_all({'Tablet', 'Library Keycard V'}, player)), LocationData('Library', 'Library: V terminal 3 (Vilete)', 1337162, lambda state: state.has_all({'Tablet', 'Library Keycard V'}, player)), diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index ee48f60eaca4..bd2498a56a35 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -762,7 +762,10 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Beneath the Vault Ladder Exit"].connect( connecting_region=regions["Beneath the Vault Main"], rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, world) - and has_lantern(state, world)) + and has_lantern(state, world) + # there's some boxes in the way + and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand, laurels), player))) + # on the reverse trip, you can lure an enemy over to break the boxes if needed regions["Beneath the Vault Main"].connect( connecting_region=regions["Beneath the Vault Ladder Exit"], rule=lambda state: has_ladder("Ladder to Beneath the Vault", state, world)) diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 14ed84d44964..aa69666daeb6 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -114,7 +114,9 @@ def set_region_rules(world: "TunicWorld") -> None: or can_ladder_storage(state, world) # using laurels or ls to get in is covered by the -> Eastern Vault Fortress rules world.get_entrance("Overworld -> Beneath the Vault").access_rule = \ - lambda state: has_lantern(state, world) and has_ability(prayer, state, world) + lambda state: (has_lantern(state, world) and has_ability(prayer, state, world) + # there's some boxes in the way + and (has_stick(state, player) or state.has_any((gun, grapple, fire_wand), player))) world.get_entrance("Ruined Atoll -> Library").access_rule = \ lambda state: state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world) world.get_entrance("Overworld -> Quarry").access_rule = \ diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index b4b38c883e7d..c9848f2ffe47 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -14,7 +14,7 @@ from .data import static_locations as static_witness_locations from .data import static_logic as static_witness_logic from .data.item_definition_classes import DoorItemDefinition, ItemData -from .data.utils import get_audio_logs +from .data.utils import cast_not_none, get_audio_logs from .hints import CompactHintData, create_all_hints, make_compact_hint_data, make_laser_hints from .locations import WitnessPlayerLocations from .options import TheWitnessOptions, witness_option_groups @@ -55,7 +55,7 @@ class WitnessWorld(World): item_name_to_id = { # ITEM_DATA doesn't have any event items in it - name: cast(int, data.ap_code) for name, data in static_witness_items.ITEM_DATA.items() + name: cast_not_none(data.ap_code) for name, data in static_witness_items.ITEM_DATA.items() } location_name_to_id = static_witness_locations.ALL_LOCATIONS_TO_ID item_name_groups = static_witness_items.ITEM_GROUPS @@ -336,7 +336,7 @@ def fill_slot_data(self) -> Dict[str, Any]: for item_name, hint in laser_hints.items(): item_def = cast(DoorItemDefinition, static_witness_logic.ALL_ITEMS[item_name]) self.laser_ids_to_hints[int(item_def.panel_id_hexes[0], 16)] = make_compact_hint_data(hint, self.player) - already_hinted_locations.add(cast(Location, hint.location)) + already_hinted_locations.add(cast_not_none(hint.location)) # Audio Log Hints diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index 84eca5afc43f..737daff70fae 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -1,7 +1,7 @@ from math import floor from pkgutil import get_data from random import Random -from typing import Any, Collection, Dict, FrozenSet, Iterable, List, Set, Tuple, TypeVar +from typing import Any, Collection, Dict, FrozenSet, Iterable, List, Optional, Set, Tuple, TypeVar T = TypeVar("T") @@ -13,6 +13,11 @@ WitnessRule = FrozenSet[FrozenSet[str]] +def cast_not_none(value: Optional[T]) -> T: + assert value is not None + return value + + def weighted_sample(world_random: Random, population: List[T], weights: List[float], k: int) -> List[T]: positions = range(len(population)) indices: List[int] = [] diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 72dfc2b7ee54..4c98cb78495e 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -15,7 +15,7 @@ ProgressiveItemDefinition, WeightedItemDefinition, ) -from .data.utils import build_weighted_int_list +from .data.utils import build_weighted_int_list, cast_not_none from .locations import WitnessPlayerLocations from .player_logic import WitnessPlayerLogic @@ -200,7 +200,7 @@ def get_symbol_ids_not_in_pool(self) -> List[int]: """ return [ # data.ap_code is guaranteed for a symbol definition - cast(int, data.ap_code) for name, data in static_witness_items.ITEM_DATA.items() + cast_not_none(data.ap_code) for name, data in static_witness_items.ITEM_DATA.items() if name not in self.item_data.keys() and data.definition.category is ItemCategory.SYMBOL ] @@ -211,8 +211,8 @@ def get_progressive_item_ids_in_pool(self) -> Dict[int, List[int]]: if isinstance(item.definition, ProgressiveItemDefinition): # Note: we need to reference the static table here rather than the player-specific one because the child # items were removed from the pool when we pruned out all progression items not in the options. - output[cast(int, item.ap_code)] = [cast(int, static_witness_items.ITEM_DATA[child_item].ap_code) - for child_item in item.definition.child_item_names] + output[cast_not_none(item.ap_code)] = [cast_not_none(static_witness_items.ITEM_DATA[child_item].ap_code) + for child_item in item.definition.child_item_names] return output diff --git a/worlds/witness/test/__init__.py b/worlds/witness/test/__init__.py index 4453609ddcdb..c3b427851af0 100644 --- a/worlds/witness/test/__init__.py +++ b/worlds/witness/test/__init__.py @@ -1,4 +1,4 @@ -from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union, cast +from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union from BaseClasses import CollectionState, Entrance, Item, Location, Region @@ -7,6 +7,7 @@ from test.multiworld.test_multiworlds import MultiworldTestBase from .. import WitnessWorld +from ..data.utils import cast_not_none class WitnessTestBase(WorldTestBase): @@ -32,7 +33,7 @@ def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance event_items = [item for item in self.multiworld.get_items() if item.name == item_name] self.assertTrue(event_items, f"Event item {item_name} does not exist.") - event_locations = [cast(Location, event_item.location) for event_item in event_items] + event_locations = [cast_not_none(event_item.location) for event_item in event_items] # Checking for an access dependency on an event item requires a bit of extra work, # as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it. diff --git a/worlds/yachtdice/Items.py b/worlds/yachtdice/Items.py index fa52c93ad6f2..c76dc538146e 100644 --- a/worlds/yachtdice/Items.py +++ b/worlds/yachtdice/Items.py @@ -16,8 +16,6 @@ class YachtDiceItem(Item): item_table = { - # victory item, always placed manually at goal location - "Victory": ItemData(16871244000 - 1, ItemClassification.progression), "Dice": ItemData(16871244000, ItemClassification.progression), "Dice Fragment": ItemData(16871244001, ItemClassification.progression), "Roll": ItemData(16871244002, ItemClassification.progression), diff --git a/worlds/yachtdice/__init__.py b/worlds/yachtdice/__init__.py index d86ee3382d33..75993fd39443 100644 --- a/worlds/yachtdice/__init__.py +++ b/worlds/yachtdice/__init__.py @@ -1,7 +1,7 @@ import math from typing import Dict -from BaseClasses import CollectionState, Entrance, Item, Region, Tutorial +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region, Tutorial from worlds.AutoWorld import WebWorld, World @@ -56,7 +56,7 @@ class YachtDiceWorld(World): item_name_groups = item_groups - ap_world_version = "2.1.2" + ap_world_version = "2.1.3" def _get_yachtdice_data(self): return { @@ -456,10 +456,12 @@ def create_regions(self): if loc_data.region == board.name ] - # Add the victory item to the correct location. - # The website declares that the game is complete when the victory item is obtained. + # Change the victory location to an event and place the Victory item there. victory_location_name = f"{self.goal_score} score" - self.get_location(victory_location_name).place_locked_item(self.create_item("Victory")) + self.get_location(victory_location_name).address = None + self.get_location(victory_location_name).place_locked_item( + Item("Victory", ItemClassification.progression, None, self.player) + ) # add the regions connection = Entrance(self.player, "New Board", menu)