From a6ad4a8293bd3612713674556227734aff91c66d Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Sat, 5 Apr 2025 13:51:22 -0400 Subject: [PATCH 01/39] Docs: Remove false claim that rules can be done in generate_basic (#4809) --- docs/world api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/world api.md b/docs/world api.md index 9e3fe67b4f..e55d4fb9f5 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -606,8 +606,8 @@ from .items import get_item_type def set_rules(self) -> None: # For some worlds this step can be omitted if either a Logic mixin - # (see below) is used, it's easier to apply the rules from data during - # location generation or everything is in generate_basic + # (see below) is used or it's easier to apply the rules from data during + # location generation # set a simple rule for an region set_rule(self.multiworld.get_entrance("Boss Door", self.player), From cad217af19f94b63a672bd0a94df91b6e87da6f5 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 6 Apr 2025 05:31:14 +0200 Subject: [PATCH 02/39] Core: update cert file daily in customserver.py (#4454) --- WebHostLib/customserver.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 76a2b8a4dc..301a386834 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -247,8 +247,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, raise Exception("Worlds system should not be loaded in the custom server.") import gc - ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None - del cert_file, cert_key_file, ponyconfig + + if not cert_file: + def get_ssl_context(): + return None + else: + load_date = None + ssl_context = load_server_cert(cert_file, cert_key_file) + + def get_ssl_context(): + nonlocal load_date, ssl_context + today = datetime.date.today() + if load_date != today: + ssl_context = load_server_cert(cert_file, cert_key_file) + load_date = today + return ssl_context + + del ponyconfig gc.collect() # free intermediate objects used during setup loop = asyncio.get_event_loop() @@ -263,12 +278,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, assert ctx.server is None try: ctx.server = websockets.serve( - functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) + functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context()) await ctx.server except OSError: # likely port in use ctx.server = websockets.serve( - functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) + functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context()) await ctx.server port = 0 From 63fbcc5fc8163156e745ed58149be252d4a5ce23 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 6 Apr 2025 13:50:24 +0200 Subject: [PATCH 03/39] WebHost: custom proc title for Generator and MultiHoster (#4310) * WebHost: custom proctitle for Generator and MultiHoster * Update setproctitle to 1.3.5 --- WebHostLib/autolauncher.py | 17 +++++++++++++++-- WebHostLib/customserver.py | 3 +++ WebHostLib/requirements.txt | 1 + 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 8ba093e014..b330146277 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -9,7 +9,7 @@ from threading import Event, Thread from typing import Any from uuid import UUID -from pony.orm import db_session, select, commit +from pony.orm import db_session, select, commit, PrimaryKey from Utils import restricted_loads from .locker import Locker, AlreadyRunningException @@ -36,12 +36,21 @@ def handle_generation_failure(result: BaseException): logging.exception(e) +def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None: + from setproctitle import setproctitle + + setproctitle(f"Generator ({sid})") + res = gen_game(gen_options, meta=meta, owner=owner, sid=sid) + setproctitle(f"Generator (idle)") + return res + + def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): try: meta = json.loads(generation.meta) options = restricted_loads(generation.options) logging.info(f"Generating {generation.id} for {len(options)} players") - pool.apply_async(gen_game, (options,), + pool.apply_async(_mp_gen_game, (options,), {"meta": meta, "sid": generation.id, "owner": generation.owner}, @@ -55,6 +64,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): def init_generator(config: dict[str, Any]) -> None: + from setproctitle import setproctitle + + setproctitle("Generator (idle)") + try: import resource except ModuleNotFoundError: diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 301a386834..2ebb40d673 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -227,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger: def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue): + from setproctitle import setproctitle + + setproctitle(name) Utils.init_logging(name) try: import resource diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 190409d9a2..a9cd33dd6d 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -9,3 +9,4 @@ bokeh>=3.6.3 markupsafe>=3.0.2 Markdown>=3.7 mdx-breakless-lists>=1.0.1 +setproctitle>=1.3.5 From 8c794995737b17b2ed94f2fc088d4c8c2e36f997 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 6 Apr 2025 17:00:14 +0200 Subject: [PATCH 04/39] SoE: remove use of deprecated Utils.get_options() (#4821) --- worlds/soe/patch.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/worlds/soe/patch.py b/worlds/soe/patch.py index a322de2af6..fba71b7bd3 100644 --- a/worlds/soe/patch.py +++ b/worlds/soe/patch.py @@ -4,7 +4,6 @@ from typing import BinaryIO, Optional import Utils from worlds.Files import APDeltaPatch - USHASH = '6e9c94511d04fac6e0a1e582c170be3a' @@ -20,9 +19,9 @@ class SoEDeltaPatch(APDeltaPatch): def get_base_rom_path(file_name: Optional[str] = None) -> str: - options = Utils.get_options() if not file_name: - file_name = options["soe_options"]["rom_file"] + from . import SoEWorld + file_name = SoEWorld.settings.rom_file if not file_name: raise ValueError("Missing soe_options -> rom_file from host.yaml") if not os.path.exists(file_name): From ac26f8be8bf75cab5ff9282c8c7174cdea7b755f Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Sun, 6 Apr 2025 13:44:33 -0400 Subject: [PATCH 05/39] Lingo: Mark some items as ProgUseful (#4822) --- worlds/lingo/items.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/worlds/lingo/items.py b/worlds/lingo/items.py index 7e75cc76c7..b773caeb4e 100644 --- a/worlds/lingo/items.py +++ b/worlds/lingo/items.py @@ -34,12 +34,34 @@ ITEMS_BY_GROUP: Dict[str, List[str]] = {} TRAP_ITEMS: List[str] = ["Slowness Trap", "Iceland Trap", "Atbash Trap"] +PROGUSEFUL_ITEMS: List[str] = [ + "Crossroads - Roof Access", + "Black", + "Red", + "Blue", + "Yellow", + "Purple", + "Sunwarps", + "Tenacious Entrance Panels", + "The Tenacious - Black Palindromes (Panels)", + "Hub Room - RAT (Panel)", + "Outside The Wanderer - WANDERLUST (Panel)", + "Orange Tower Panels" +] + + +def get_prog_item_classification(item_name: str): + if item_name in PROGUSEFUL_ITEMS: + return ItemClassification.progression | ItemClassification.useful + else: + return ItemClassification.progression + def load_item_data(): global ALL_ITEM_TABLE, ITEMS_BY_GROUP for color in ["Black", "Red", "Blue", "Yellow", "Green", "Orange", "Gray", "Brown", "Purple"]: - ALL_ITEM_TABLE[color] = ItemData(get_special_item_id(color), ItemClassification.progression, + ALL_ITEM_TABLE[color] = ItemData(get_special_item_id(color), get_prog_item_classification(color), ItemType.COLOR, False, []) ITEMS_BY_GROUP.setdefault("Colors", []).append(color) @@ -53,16 +75,16 @@ def load_item_data(): door_groups.add(door.door_group) ALL_ITEM_TABLE[door.item_name] = \ - ItemData(get_door_item_id(room_name, door_name), ItemClassification.progression, ItemType.NORMAL, - door.has_doors, door.painting_ids) + ItemData(get_door_item_id(room_name, door_name), get_prog_item_classification(door.item_name), + ItemType.NORMAL, door.has_doors, door.painting_ids) ITEMS_BY_GROUP.setdefault("Doors", []).append(door.item_name) if door.item_group is not None: ITEMS_BY_GROUP.setdefault(door.item_group, []).append(door.item_name) for group in door_groups: - ALL_ITEM_TABLE[group] = ItemData(get_door_group_item_id(group), - ItemClassification.progression, ItemType.NORMAL, True, []) + ALL_ITEM_TABLE[group] = ItemData(get_door_group_item_id(group), get_prog_item_classification(group), + ItemType.NORMAL, True, []) ITEMS_BY_GROUP.setdefault("Doors", []).append(group) panel_groups: Set[str] = set() @@ -72,11 +94,12 @@ def load_item_data(): panel_groups.add(panel_door.panel_group) ALL_ITEM_TABLE[panel_door.item_name] = ItemData(get_panel_door_item_id(room_name, panel_door_name), - ItemClassification.progression, ItemType.NORMAL, False, []) + get_prog_item_classification(panel_door.item_name), + ItemType.NORMAL, False, []) ITEMS_BY_GROUP.setdefault("Panels", []).append(panel_door.item_name) for group in panel_groups: - ALL_ITEM_TABLE[group] = ItemData(get_panel_group_item_id(group), ItemClassification.progression, + ALL_ITEM_TABLE[group] = ItemData(get_panel_group_item_id(group), get_prog_item_classification(group), ItemType.NORMAL, False, []) ITEMS_BY_GROUP.setdefault("Panels", []).append(group) @@ -101,7 +124,7 @@ def load_item_data(): for item_name in PROGRESSIVE_ITEMS: ALL_ITEM_TABLE[item_name] = ItemData(get_progressive_item_id(item_name), - ItemClassification.progression, ItemType.NORMAL, False, []) + get_prog_item_classification(item_name), ItemType.NORMAL, False, []) # Initialize the item data at module scope. From dc4e8bae9839e3ab486b7f0522631a508e876edd Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Sun, 6 Apr 2025 13:11:16 -0500 Subject: [PATCH 06/39] Core: post-KivyMD cleanup (#4815) * Removed now unused imports from Launcher * Moved ImageIcon and ImageButton to use ApAsyncImage for compatibility with apworlds * Adjusted image size in the Launcher from 40x40 to 48x48. This is already larger than the size in previous versions, and a docs update is soon to follow. * Expose `dynamic_scheme_contrast` to user.kv, allowing users to set high contrast. * ScrollBox's default scroll_type was set to only content, so the scrollbar in Launcher was nonfunctional. * Adjusted the spacing of the title of a component when a description is present to be closer to the center. * Launcher now scrolls to the top automatically when changing between filters --- Launcher.py | 13 +++++++------ data/client.kv | 2 ++ data/launcher.kv | 4 ++-- kvui.py | 6 +++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Launcher.py b/Launcher.py index 609c109470..d636ceab74 100644 --- a/Launcher.py +++ b/Launcher.py @@ -8,9 +8,7 @@ Archipelago Launcher Scroll down to components= to add components to the launcher as well as setup.py """ -import os import argparse -import itertools import logging import multiprocessing import shlex @@ -132,7 +130,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText from kivymd.uix.divider import MDDivider - if client_component is None: + if not client_component: run_component(text_client_component, *launch_args) return else: @@ -228,14 +226,13 @@ refresh_components: Optional[Callable[[], None]] = None def run_gui(path: str, args: Any) -> None: - from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, MDButton, MDLabel, MDButtonText, ScrollBox, ApAsyncImage) + from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox) from kivy.properties import ObjectProperty from kivy.core.window import Window from kivy.metrics import dp from kivymd.uix.button import MDIconButton from kivymd.uix.card import MDCard from kivymd.uix.menu import MDDropdownMenu - from kivymd.uix.relativelayout import MDRelativeLayout from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText from kivy.lang.builder import Builder @@ -250,7 +247,6 @@ def run_gui(path: str, args: Any) -> None: self.image = image_path super().__init__(args, kwargs) - class Launcher(ThemedApp): base_title: str = "Archipelago Launcher" top_screen: MDFloatLayout = ObjectProperty(None) @@ -337,6 +333,11 @@ def run_gui(path: str, args: Any) -> None: for card in cards: self.button_layout.layout.add_widget(card) + top = self.button_layout.children[0].y + self.button_layout.children[0].height \ + - self.button_layout.height + scroll_percent = self.button_layout.convert_distance_to_scroll(0, top) + self.button_layout.scroll_y = max(0, min(1, scroll_percent[1])) + def filter_clients(self, caller): self._refresh_components(caller.type) diff --git a/data/client.kv b/data/client.kv index 0974258d6c..ac0a45023c 100644 --- a/data/client.kv +++ b/data/client.kv @@ -18,6 +18,7 @@ theme_style: "Dark" # Light/Dark primary_palette: "Green" # Many options dynamic_scheme_name: "TONAL_SPOT" + dynamic_scheme_contrast: 0.0 : color: self.theme_cls.primaryColor : @@ -184,6 +185,7 @@ bar_width: "12dp" scroll_wheel_distance: 40 do_scroll_x: False + scroll_type: ['bars', 'content'] MDBoxLayout: id: layout diff --git a/data/launcher.kv b/data/launcher.kv index 0e569d7ea9..03e1c3e078 100644 --- a/data/launcher.kv +++ b/data/launcher.kv @@ -9,13 +9,13 @@ MDRelativeLayout: ApAsyncImage: source: main.image - size: (40, 40) + size: (48, 48) size_hint_y: None pos_hint: {"center_x": 0.1, "center_y": 0.5} MDLabel: text: main.component.display_name - pos_hint:{"center_x": 0.5, "center_y": 0.85 if main.component.description else 0.65} + pos_hint:{"center_x": 0.5, "center_y": 0.75 if main.component.description else 0.65} halign: "center" font_style: "Title" role: "medium" diff --git a/kvui.py b/kvui.py index b12cbec716..3837ab08c2 100644 --- a/kvui.py +++ b/kvui.py @@ -93,13 +93,13 @@ class ThemedApp(MDApp): self.theme_cls.theme_style = getattr(text_colors, "theme_style", "Dark") self.theme_cls.primary_palette = getattr(text_colors, "primary_palette", "Green") self.theme_cls.dynamic_scheme_name = getattr(text_colors, "dynamic_scheme_name", "TONAL_SPOT") - self.theme_cls.dynamic_scheme_contrast = 0.0 + self.theme_cls.dynamic_scheme_contrast = getattr(text_colors, "dynamic_scheme_contrast", 0.0) class ImageIcon(MDButtonIcon, AsyncImage): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self.image = AsyncImage(**kwargs) + self.image = ApAsyncImage(**kwargs) self.add_widget(self.image) def add_widget(self, widget, index=0, canvas=None): @@ -114,7 +114,7 @@ class ImageButton(MDIconButton): if val != "None": image_args[kwarg.replace("image_", "")] = val super().__init__() - self.image = AsyncImage(**image_args) + self.image = ApAsyncImage(**image_args) def set_center(button, center): self.image.center_x = self.center_x From f03bb61747bb8378f3b8ec68389f4c515dddaae4 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Sun, 6 Apr 2025 18:02:34 -0400 Subject: [PATCH 07/39] Lingo: Add "shuffle_postgame" flag to slot data (#4825) This allows the tracker to see whether postgame is shuffled in the player's world, and if it's not, allows it to hide locations/paintings accordingly. --- worlds/lingo/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/lingo/__init__.py b/worlds/lingo/__init__.py index 141fca0743..05509a394b 100644 --- a/worlds/lingo/__init__.py +++ b/worlds/lingo/__init__.py @@ -174,7 +174,7 @@ class LingoWorld(World): "death_link", "victory_condition", "shuffle_colors", "shuffle_doors", "shuffle_paintings", "shuffle_panels", "enable_pilgrimage", "sunwarp_access", "mastery_achievements", "level_2_requirement", "location_checks", "early_color_hallways", "pilgrimage_allows_roof_access", "pilgrimage_allows_paintings", "shuffle_sunwarps", - "group_doors", "speed_boost_mode" + "group_doors", "speed_boost_mode", "shuffle_postgame" ] slot_data = { From f94492b2d35b39573e49dc7134a2167cf01cd4af Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Mon, 7 Apr 2025 00:39:25 +0200 Subject: [PATCH 08/39] CI: ignore F824 (#4790) This is an added check in flake8 that does not really fit the goal of the github action and currently throws a lot of errors. --- .github/workflows/analyze-modified-files.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index b59336fafe..6788abd30a 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -65,7 +65,7 @@ jobs: continue-on-error: false if: env.diff != '' && matrix.task == 'flake8' run: | - flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }} + flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }} - name: "flake8: Lint modified files" continue-on-error: true From 60d6078e1fbe73aa6251a9b89a1b09d4c3148b38 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:17:25 +0200 Subject: [PATCH 09/39] Wind Waker: Don't collect nonprogression #4826 --- worlds/tww/randomizers/Dungeons.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/tww/randomizers/Dungeons.py b/worlds/tww/randomizers/Dungeons.py index 3a009f78f7..f1dc0d6c97 100644 --- a/worlds/tww/randomizers/Dungeons.py +++ b/worlds/tww/randomizers/Dungeons.py @@ -254,7 +254,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld) -> None: in_dungeon_player_ids = {item.player for item in in_dungeon_items} all_state_base = CollectionState(multiworld) for item in multiworld.itempool: - multiworld.worlds[item.player].collect(all_state_base, item) + all_state_base.collect(item, prevent_sweep=True) pre_fill_items = [] for player in in_dungeon_player_ids: pre_fill_items += multiworld.worlds[player].get_pre_fill_items() @@ -265,7 +265,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld) -> None: # `pre_fill_items` should be a subset of `in_dungeon_items`, but just in case. pass for item in pre_fill_items: - multiworld.worlds[item.player].collect(all_state_base, item) + all_state_base.collect(item, prevent_sweep=True) all_state_base.sweep_for_advancements() # Remove the completion condition so that minimal-accessibility words place keys correctly. From ab2efc0c5c6e4916dcb60fe4b5857b5eb3bad28e Mon Sep 17 00:00:00 2001 From: Emily <35015090+EmilyV99@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:06:19 -0400 Subject: [PATCH 10/39] kvui: actually fix [u] and [/u] appearing in copied hints (#4842) --- kvui.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kvui.py b/kvui.py index 3837ab08c2..81e3876fe5 100644 --- a/kvui.py +++ b/kvui.py @@ -589,8 +589,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout): if self.entrance_text != "Vanilla" else "", ". (", self.status_text.lower(), ")")) temp = MarkupLabel(text).markup - text = "".join( - part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]"))) + text = "".join(part for part in temp if not part.startswith("[")) Clipboard.copy(escape_markup(text).replace("&", "&").replace("&bl;", "[").replace("&br;", "]")) return self.parent.select_with_touch(self.index, touch) else: From 286e24629faede058f287c072c34dad709f59d8b Mon Sep 17 00:00:00 2001 From: CookieCat <81494827+CookieCat45@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:26:30 -0400 Subject: [PATCH 11/39] AHIT: Add start_inventory_from_pool and get_filler_item_name (#4798) * Update __init__.py * Update Options.py --- worlds/ahit/Options.py | 4 +++- worlds/ahit/__init__.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/worlds/ahit/Options.py b/worlds/ahit/Options.py index b331ca5242..ab6ba46f19 100644 --- a/worlds/ahit/Options.py +++ b/worlds/ahit/Options.py @@ -2,7 +2,7 @@ from typing import List, TYPE_CHECKING, Dict, Any from schema import Schema, Optional from dataclasses import dataclass from worlds.AutoWorld import PerGameCommonOptions -from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup +from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup, StartInventoryPool if TYPE_CHECKING: from . import HatInTimeWorld @@ -625,6 +625,8 @@ class ParadeTrapWeight(Range): @dataclass class AHITOptions(PerGameCommonOptions): + start_inventory_from_pool: StartInventoryPool + EndGoal: EndGoal ActRandomizer: ActRandomizer ActPlando: ActPlando diff --git a/worlds/ahit/__init__.py b/worlds/ahit/__init__.py index c2fe39872f..16b54064c6 100644 --- a/worlds/ahit/__init__.py +++ b/worlds/ahit/__init__.py @@ -1,6 +1,6 @@ from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \ - calculate_yarn_costs, alps_hooks + calculate_yarn_costs, alps_hooks, junk_weights from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \ get_total_locations @@ -78,6 +78,9 @@ class HatInTimeWorld(World): self.nyakuza_thug_items: Dict[str, int] = {} self.badge_seller_count: int = 0 + def get_filler_item_name(self) -> str: + return self.random.choices(list(junk_weights.keys()), weights=junk_weights.values(), k=1)[0] + def generate_early(self): adjust_options(self) From 9ac921380f2e1d79e4595181a2ac14aa7057d3bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Bolduc?= <16137441+Jouramie@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:37:45 -0400 Subject: [PATCH 12/39] Stardew Valley: Refactor buildings to use content packs (#4239) * create building data object and rename ItemSource to Source to be more generic # Conflicts: # worlds/stardew_valley/content/game_content.py # Conflicts: # worlds/stardew_valley/data/artisan.py # worlds/stardew_valley/data/game_item.py # worlds/stardew_valley/data/harvest.py # worlds/stardew_valley/data/shop.py * remove compound sources, replace by other requirements which already handle this usecase * add coops to content packs * add building progression in game features * add shippping bin to starting building; remove has_house * replace config check with feature * add other buildings in content packs * not passing * tests passes, unbelievable * use newly create methods more * use new assets to ease readability * self review * fix flake8 maybe * properly split rule for mapping cave systems * fix tractor garage name * self review * add upgrade_from to farm house buldings * don't override building name variable in logic * remove has_group from buildings * mark some items easy in grinding logic so blueprints buildings can be in more early spheres * move stuff around to maybe avoid future conflicts cuz I have like 10 PRs opened right now * remove price_multiplier, turns out it's unused during generation * disable shop source for mapping cave systems * bunch of code review changes * add petbowl and farmhouse to autobuilding * set min easy items to 300 * fix farm type --- worlds/stardew_valley/__init__.py | 15 +- worlds/stardew_valley/content/__init__.py | 30 ++- .../content/feature/__init__.py | 1 + .../content/feature/building_progression.py | 53 +++++ worlds/stardew_valley/content/game_content.py | 24 +- worlds/stardew_valley/content/mods/tractor.py | 18 ++ worlds/stardew_valley/content/unpacking.py | 8 +- .../content/vanilla/pelican_town.py | 213 +++++++++++++++++- worlds/stardew_valley/data/artisan.py | 4 +- worlds/stardew_valley/data/building.py | 16 ++ worlds/stardew_valley/data/game_item.py | 17 +- worlds/stardew_valley/data/harvest.py | 16 +- worlds/stardew_valley/data/items.csv | 1 + worlds/stardew_valley/data/requirement.py | 5 + worlds/stardew_valley/data/shop.py | 12 +- worlds/stardew_valley/early_items.py | 4 +- worlds/stardew_valley/items.py | 34 +-- worlds/stardew_valley/locations.py | 20 +- worlds/stardew_valley/logic/base_logic.py | 1 - worlds/stardew_valley/logic/building_logic.py | 112 +++------ worlds/stardew_valley/logic/cooking_logic.py | 3 +- worlds/stardew_valley/logic/goal_logic.py | 2 +- worlds/stardew_valley/logic/grind_logic.py | 37 ++- worlds/stardew_valley/logic/logic.py | 5 +- worlds/stardew_valley/logic/money_logic.py | 8 +- .../logic/relationship_logic.py | 6 +- .../stardew_valley/logic/requirement_logic.py | 11 +- worlds/stardew_valley/logic/source_logic.py | 14 +- .../mods/logic/buildings_logic.py | 28 --- worlds/stardew_valley/mods/logic/mod_logic.py | 3 +- worlds/stardew_valley/rules.py | 22 +- .../stardew_valley/strings/building_names.py | 2 + worlds/stardew_valley/test/TestBooksanity.py | 16 +- worlds/stardew_valley/test/TestCrops.py | 20 +- worlds/stardew_valley/test/TestFarmType.py | 40 +++- worlds/stardew_valley/test/TestGeneration.py | 59 ++--- worlds/stardew_valley/test/TestLogic.py | 4 +- worlds/stardew_valley/test/TestOptionFlags.py | 18 +- .../stardew_valley/test/TestWalnutsanity.py | 9 +- worlds/stardew_valley/test/__init__.py | 35 ++- .../stardew_valley/test/content/__init__.py | 2 + .../test/long/TestOptionsLong.py | 9 +- worlds/stardew_valley/test/mods/TestMods.py | 31 +-- worlds/stardew_valley/test/options/presets.py | 4 + .../stardew_valley/test/rules/TestArcades.py | 12 +- worlds/stardew_valley/test/rules/TestBooks.py | 13 +- .../test/rules/TestBuildings.py | 32 +-- .../stardew_valley/test/rules/TestBundles.py | 23 +- .../test/rules/TestCookingRecipes.py | 25 +- .../test/rules/TestCraftingRecipes.py | 32 ++- .../test/rules/TestDonations.py | 12 +- .../stardew_valley/test/rules/TestFishing.py | 7 +- .../test/rules/TestFriendship.py | 8 +- .../stardew_valley/test/rules/TestShipping.py | 6 +- .../stardew_valley/test/rules/TestSkills.py | 14 +- worlds/stardew_valley/test/rules/TestTools.py | 41 ++-- 56 files changed, 757 insertions(+), 460 deletions(-) create mode 100644 worlds/stardew_valley/content/feature/building_progression.py create mode 100644 worlds/stardew_valley/data/building.py delete mode 100644 worlds/stardew_valley/mods/logic/buildings_logic.py diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py index 83a1c11c18..bf900742b9 100644 --- a/worlds/stardew_valley/__init__.py +++ b/worlds/stardew_valley/__init__.py @@ -145,7 +145,7 @@ class StardewValleyWorld(World): def create_items(self): self.precollect_starting_season() - self.precollect_farm_type_items() + self.precollect_building_items() items_to_exclude = [excluded_items for excluded_items in self.multiworld.precollected_items[self.player] if not item_table[excluded_items.name].has_any_group(Group.RESOURCE_PACK, @@ -200,9 +200,16 @@ class StardewValleyWorld(World): starting_season = self.create_item(self.random.choice(season_pool)) self.multiworld.push_precollected(starting_season) - def precollect_farm_type_items(self): - if self.options.farm_type == FarmType.option_meadowlands and self.options.building_progression & BuildingProgression.option_progressive: - self.multiworld.push_precollected(self.create_item("Progressive Coop")) + def precollect_building_items(self): + building_progression = self.content.features.building_progression + # Not adding items when building are vanilla because the buildings are already placed in the world. + if not building_progression.is_progressive: + return + + for building in building_progression.starting_buildings: + item, quantity = building_progression.to_progressive_item(building) + for _ in range(quantity): + self.multiworld.push_precollected(self.create_item(item)) def setup_logic_events(self): def register_event(name: str, region: str, rule: StardewRule): diff --git a/worlds/stardew_valley/content/__init__.py b/worlds/stardew_valley/content/__init__.py index 5308506437..33608531d9 100644 --- a/worlds/stardew_valley/content/__init__.py +++ b/worlds/stardew_valley/content/__init__.py @@ -1,8 +1,9 @@ from . import content_packs -from .feature import cropsanity, friendsanity, fishsanity, booksanity, skill_progression, tool_progression +from .feature import cropsanity, friendsanity, fishsanity, booksanity, building_progression, skill_progression, tool_progression from .game_content import ContentPack, StardewContent, StardewFeatures from .unpacking import unpack_content from .. import options +from ..strings.building_names import Building def create_content(player_options: options.StardewValleyOptions) -> StardewContent: @@ -29,6 +30,7 @@ def choose_content_packs(player_options: options.StardewValleyOptions): def choose_features(player_options: options.StardewValleyOptions) -> StardewFeatures: return StardewFeatures( choose_booksanity(player_options.booksanity), + choose_building_progression(player_options.building_progression, player_options.farm_type), choose_cropsanity(player_options.cropsanity), choose_fishsanity(player_options.fishsanity), choose_friendsanity(player_options.friendsanity, player_options.friendsanity_heart_size), @@ -109,6 +111,32 @@ def choose_friendsanity(friendsanity_option: options.Friendsanity, heart_size: o raise ValueError(f"No friendsanity feature mapped to {str(friendsanity_option.value)}") +def choose_building_progression(building_option: options.BuildingProgression, + farm_type_option: options.FarmType) -> building_progression.BuildingProgressionFeature: + starting_buildings = {Building.farm_house, Building.pet_bowl, Building.shipping_bin} + + if farm_type_option == options.FarmType.option_meadowlands: + starting_buildings.add(Building.coop) + + if (building_option == options.BuildingProgression.option_vanilla + or building_option == options.BuildingProgression.option_vanilla_cheap + or building_option == options.BuildingProgression.option_vanilla_very_cheap): + return building_progression.BuildingProgressionVanilla( + starting_buildings=starting_buildings, + ) + + starting_buildings.remove(Building.shipping_bin) + + if (building_option == options.BuildingProgression.option_progressive + or building_option == options.BuildingProgression.option_progressive_cheap + or building_option == options.BuildingProgression.option_progressive_very_cheap): + return building_progression.BuildingProgressionProgressive( + starting_buildings=starting_buildings, + ) + + raise ValueError(f"No building progression feature mapped to {str(building_option.value)}") + + skill_progression_by_option = { options.SkillProgression.option_vanilla: skill_progression.SkillProgressionVanilla(), options.SkillProgression.option_progressive: skill_progression.SkillProgressionProgressive(), diff --git a/worlds/stardew_valley/content/feature/__init__.py b/worlds/stardew_valley/content/feature/__init__.py index eb23f8105b..b2a88286c6 100644 --- a/worlds/stardew_valley/content/feature/__init__.py +++ b/worlds/stardew_valley/content/feature/__init__.py @@ -1,4 +1,5 @@ from . import booksanity +from . import building_progression from . import cropsanity from . import fishsanity from . import friendsanity diff --git a/worlds/stardew_valley/content/feature/building_progression.py b/worlds/stardew_valley/content/feature/building_progression.py new file mode 100644 index 0000000000..0d317aa018 --- /dev/null +++ b/worlds/stardew_valley/content/feature/building_progression.py @@ -0,0 +1,53 @@ +from abc import ABC +from dataclasses import dataclass +from typing import ClassVar, Set, Tuple + +from ...strings.building_names import Building + +progressive_house = "Progressive House" + +# This assumes that the farm house is always available, which might not be true forever... +progressive_house_by_upgrade_name = { + Building.farm_house: 0, + Building.kitchen: 1, + Building.kids_room: 2, + Building.cellar: 3 +} + + +def to_progressive_item(building: str) -> Tuple[str, int]: + """Return the name of the progressive item and its quantity required to unlock the building. + """ + if building in [Building.coop, Building.barn, Building.shed]: + return f"Progressive {building}", 1 + elif building.startswith("Big"): + return f"Progressive {building[building.index(' ') + 1:]}", 2 + elif building.startswith("Deluxe"): + return f"Progressive {building[building.index(' ') + 1:]}", 3 + elif building in progressive_house_by_upgrade_name: + return progressive_house, progressive_house_by_upgrade_name[building] + + return building, 1 + + +def to_location_name(building: str) -> str: + return f"{building} Blueprint" + + +@dataclass(frozen=True) +class BuildingProgressionFeature(ABC): + is_progressive: ClassVar[bool] + starting_buildings: Set[str] + + to_progressive_item = staticmethod(to_progressive_item) + progressive_house = progressive_house + + to_location_name = staticmethod(to_location_name) + + +class BuildingProgressionVanilla(BuildingProgressionFeature): + is_progressive = False + + +class BuildingProgressionProgressive(BuildingProgressionFeature): + is_progressive = True diff --git a/worlds/stardew_valley/content/game_content.py b/worlds/stardew_valley/content/game_content.py index 3aa3350f47..8a72a4811d 100644 --- a/worlds/stardew_valley/content/game_content.py +++ b/worlds/stardew_valley/content/game_content.py @@ -3,9 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Dict, Iterable, Set, Any, Mapping, Type, Tuple, Union -from .feature import booksanity, cropsanity, fishsanity, friendsanity, skill_progression, tool_progression +from .feature import booksanity, cropsanity, fishsanity, friendsanity, skill_progression, building_progression, tool_progression +from ..data.building import Building from ..data.fish_data import FishItem -from ..data.game_item import GameItem, ItemSource, ItemTag +from ..data.game_item import GameItem, Source, ItemTag from ..data.skill import Skill from ..data.villagers_data import Villager @@ -20,16 +21,17 @@ class StardewContent: game_items: Dict[str, GameItem] = field(default_factory=dict) fishes: Dict[str, FishItem] = field(default_factory=dict) villagers: Dict[str, Villager] = field(default_factory=dict) + farm_buildings: Dict[str, Building] = field(default_factory=dict) skills: Dict[str, Skill] = field(default_factory=dict) quests: Dict[str, Any] = field(default_factory=dict) - def find_sources_of_type(self, types: Union[Type[ItemSource], Tuple[Type[ItemSource]]]) -> Iterable[ItemSource]: + def find_sources_of_type(self, types: Union[Type[Source], Tuple[Type[Source]]]) -> Iterable[Source]: for item in self.game_items.values(): for source in item.sources: if isinstance(source, types): yield source - def source_item(self, item_name: str, *sources: ItemSource): + def source_item(self, item_name: str, *sources: Source): item = self.game_items.setdefault(item_name, GameItem(item_name)) item.add_sources(sources) @@ -50,6 +52,7 @@ class StardewContent: @dataclass(frozen=True) class StardewFeatures: booksanity: booksanity.BooksanityFeature + building_progression: building_progression.BuildingProgressionFeature cropsanity: cropsanity.CropsanityFeature fishsanity: fishsanity.FishsanityFeature friendsanity: friendsanity.FriendsanityFeature @@ -70,13 +73,13 @@ class ContentPack: # def item_hook # ... - harvest_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict) + harvest_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict) """Harvest sources contains both crops and forageables, but also fruits from trees, the cave farm and stuff harvested from tapping like maple syrup.""" def harvest_source_hook(self, content: StardewContent): ... - shop_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict) + shop_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict) def shop_source_hook(self, content: StardewContent): ... @@ -86,12 +89,12 @@ class ContentPack: def fish_hook(self, content: StardewContent): ... - crafting_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict) + crafting_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict) def crafting_hook(self, content: StardewContent): ... - artisan_good_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict) + artisan_good_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict) def artisan_good_hook(self, content: StardewContent): ... @@ -101,6 +104,11 @@ class ContentPack: def villager_hook(self, content: StardewContent): ... + farm_buildings: Iterable[Building] = () + + def farm_building_hook(self, content: StardewContent): + ... + skills: Iterable[Skill] = () def skill_hook(self, content: StardewContent): diff --git a/worlds/stardew_valley/content/mods/tractor.py b/worlds/stardew_valley/content/mods/tractor.py index 8f14300179..a672bc2bf9 100644 --- a/worlds/stardew_valley/content/mods/tractor.py +++ b/worlds/stardew_valley/content/mods/tractor.py @@ -1,7 +1,25 @@ from ..game_content import ContentPack from ..mod_registry import register_mod_content_pack +from ...data.building import Building +from ...data.shop import ShopSource from ...mods.mod_data import ModNames +from ...strings.artisan_good_names import ArtisanGood +from ...strings.building_names import ModBuilding +from ...strings.metal_names import MetalBar +from ...strings.region_names import Region register_mod_content_pack(ContentPack( ModNames.tractor, + farm_buildings=( + Building( + ModBuilding.tractor_garage, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=150_000, + items_price=((20, MetalBar.iron), (5, MetalBar.iridium), (1, ArtisanGood.battery_pack)), + ), + ), + ), + ), )) diff --git a/worlds/stardew_valley/content/unpacking.py b/worlds/stardew_valley/content/unpacking.py index 3c57f91afe..2d50f7718b 100644 --- a/worlds/stardew_valley/content/unpacking.py +++ b/worlds/stardew_valley/content/unpacking.py @@ -5,7 +5,7 @@ from typing import Iterable, Mapping, Callable from .game_content import StardewContent, ContentPack, StardewFeatures from .vanilla.base import base_game as base_game_content_pack -from ..data.game_item import GameItem, ItemSource +from ..data.game_item import GameItem, Source def unpack_content(features: StardewFeatures, packs: Iterable[ContentPack]) -> StardewContent: @@ -61,6 +61,10 @@ def register_pack(content: StardewContent, pack: ContentPack): content.villagers[villager.name] = villager pack.villager_hook(content) + for building in pack.farm_buildings: + content.farm_buildings[building.name] = building + pack.farm_building_hook(content) + for skill in pack.skills: content.skills[skill.name] = skill pack.skill_hook(content) @@ -73,7 +77,7 @@ def register_pack(content: StardewContent, pack: ContentPack): def register_sources_and_call_hook(content: StardewContent, - sources_by_item_name: Mapping[str, Iterable[ItemSource]], + sources_by_item_name: Mapping[str, Iterable[Source]], hook: Callable[[StardewContent], None]): for item_name, sources in sources_by_item_name.items(): item = content.game_items.setdefault(item_name, GameItem(item_name)) diff --git a/worlds/stardew_valley/content/vanilla/pelican_town.py b/worlds/stardew_valley/content/vanilla/pelican_town.py index 913fe4b8ad..aeae4c1431 100644 --- a/worlds/stardew_valley/content/vanilla/pelican_town.py +++ b/worlds/stardew_valley/content/vanilla/pelican_town.py @@ -1,10 +1,13 @@ from ..game_content import ContentPack from ...data import villagers_data, fish_data -from ...data.game_item import GenericSource, ItemTag, Tag, CustomRuleSource, CompoundSource +from ...data.building import Building +from ...data.game_item import GenericSource, ItemTag, Tag, CustomRuleSource from ...data.harvest import ForagingSource, SeasonalForagingSource, ArtifactSpotSource from ...data.requirement import ToolRequirement, BookRequirement, SkillRequirement from ...data.shop import ShopSource, MysteryBoxSource, ArtifactTroveSource, PrizeMachineSource, FishingTreasureChestSource +from ...strings.artisan_good_names import ArtisanGood from ...strings.book_names import Book +from ...strings.building_names import Building as BuildingNames from ...strings.crop_names import Fruit from ...strings.fish_names import WaterItem from ...strings.food_names import Beverage, Meal @@ -12,6 +15,7 @@ from ...strings.forageable_names import Forageable, Mushroom from ...strings.fruit_tree_names import Sapling from ...strings.generic_names import Generic from ...strings.material_names import Material +from ...strings.metal_names import MetalBar from ...strings.region_names import Region, LogicRegion from ...strings.season_names import Season from ...strings.seed_names import Seed, TreeSeed @@ -229,10 +233,10 @@ pelican_town = ContentPack( ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),), Book.mapping_cave_systems: ( Tag(ItemTag.BOOK, ItemTag.BOOK_POWER), - CompoundSource(sources=( - GenericSource(regions=(Region.adventurer_guild_bedroom,)), - ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3), - ))), + GenericSource(regions=(Region.adventurer_guild_bedroom,)), + # Disabling the shop source for better game design. + # ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3), + ), Book.monster_compendium: ( Tag(ItemTag.BOOK, ItemTag.BOOK_POWER), CustomRuleSource(create_rule=lambda logic: logic.monster.can_kill_many(Generic.any)), @@ -385,5 +389,204 @@ pelican_town = ContentPack( villagers_data.vincent, villagers_data.willy, villagers_data.wizard, + ), + farm_buildings=( + Building( + BuildingNames.barn, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=6000, + items_price=((350, Material.wood), (150, Material.stone)) + ), + ), + ), + Building( + BuildingNames.big_barn, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=12_000, + items_price=((450, Material.wood), (200, Material.stone)) + ), + ), + upgrade_from=BuildingNames.barn, + ), + Building( + BuildingNames.deluxe_barn, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=25_000, + items_price=((550, Material.wood), (300, Material.stone)) + ), + ), + upgrade_from=BuildingNames.big_barn, + ), + Building( + BuildingNames.coop, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=4000, + items_price=((300, Material.wood), (100, Material.stone)) + ), + ), + ), + Building( + BuildingNames.big_coop, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=10_000, + items_price=((400, Material.wood), (150, Material.stone)) + ), + ), + upgrade_from=BuildingNames.coop, + ), + Building( + BuildingNames.deluxe_coop, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=20_000, + items_price=((500, Material.wood), (200, Material.stone)) + ), + ), + upgrade_from=BuildingNames.big_coop, + ), + Building( + BuildingNames.fish_pond, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=5000, + items_price=((200, Material.stone), (5, WaterItem.seaweed), (5, WaterItem.green_algae)) + ), + ), + ), + Building( + BuildingNames.mill, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=2500, + items_price=((50, Material.stone), (150, Material.wood), (4, ArtisanGood.cloth)) + ), + ), + ), + Building( + BuildingNames.shed, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=15_000, + items_price=((300, Material.wood),) + ), + ), + ), + Building( + BuildingNames.big_shed, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=20_000, + items_price=((550, Material.wood), (300, Material.stone)) + ), + ), + upgrade_from=BuildingNames.shed, + ), + Building( + BuildingNames.silo, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=100, + items_price=((100, Material.stone), (10, Material.clay), (5, MetalBar.copper)) + ), + ), + ), + Building( + BuildingNames.slime_hutch, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=10_000, + items_price=((500, Material.stone), (10, MetalBar.quartz), (1, MetalBar.iridium)) + ), + ), + ), + Building( + BuildingNames.stable, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=10_000, + items_price=((100, Material.hardwood), (5, MetalBar.iron)) + ), + ), + ), + Building( + BuildingNames.well, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=1000, + items_price=((75, Material.stone),) + ), + ), + ), + Building( + BuildingNames.shipping_bin, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=250, + items_price=((150, Material.wood),) + ), + ), + ), + Building( + BuildingNames.pet_bowl, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=5000, + items_price=((25, Material.hardwood),) + ), + ), + ), + Building( + BuildingNames.kitchen, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=10_000, + items_price=((450, Material.wood),) + ), + ), + upgrade_from=BuildingNames.farm_house, + ), + Building( + BuildingNames.kids_room, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=65_000, + items_price=((100, Material.hardwood),) + ), + ), + upgrade_from=BuildingNames.kitchen, + ), + Building( + BuildingNames.cellar, + sources=( + ShopSource( + shop_region=Region.carpenter, + money_price=100_000, + ), + ), + upgrade_from=BuildingNames.kids_room, + ), ) ) diff --git a/worlds/stardew_valley/data/artisan.py b/worlds/stardew_valley/data/artisan.py index 90be5b1684..a4a722c797 100644 --- a/worlds/stardew_valley/data/artisan.py +++ b/worlds/stardew_valley/data/artisan.py @@ -1,10 +1,10 @@ from dataclasses import dataclass -from .game_item import ItemSource +from .game_item import Source @dataclass(frozen=True, kw_only=True) -class MachineSource(ItemSource): +class MachineSource(Source): item: str # this should be optional (worm bin) machine: str # seasons diff --git a/worlds/stardew_valley/data/building.py b/worlds/stardew_valley/data/building.py new file mode 100644 index 0000000000..a3adf77c58 --- /dev/null +++ b/worlds/stardew_valley/data/building.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field +from functools import cached_property +from typing import Optional, Tuple + +from .game_item import Source + + +@dataclass(frozen=True) +class Building: + name: str + sources: Tuple[Source, ...] = field(kw_only=True) + upgrade_from: Optional[str] = field(default=None, kw_only=True) + + @cached_property + def is_upgrade(self) -> bool: + return self.upgrade_from is not None diff --git a/worlds/stardew_valley/data/game_item.py b/worlds/stardew_valley/data/game_item.py index c6e4717cd1..6c23f59f2b 100644 --- a/worlds/stardew_valley/data/game_item.py +++ b/worlds/stardew_valley/data/game_item.py @@ -27,7 +27,7 @@ class ItemTag(enum.Enum): @dataclass(frozen=True) -class ItemSource(ABC): +class Source(ABC): add_tags: ClassVar[Tuple[ItemTag]] = () other_requirements: Tuple[Requirement, ...] = field(kw_only=True, default_factory=tuple) @@ -38,23 +38,18 @@ class ItemSource(ABC): @dataclass(frozen=True, kw_only=True) -class GenericSource(ItemSource): +class GenericSource(Source): regions: Tuple[str, ...] = () """No region means it's available everywhere.""" @dataclass(frozen=True) -class CustomRuleSource(ItemSource): +class CustomRuleSource(Source): """Hopefully once everything is migrated to sources, we won't need these custom logic anymore.""" create_rule: Callable[[Any], StardewRule] -@dataclass(frozen=True, kw_only=True) -class CompoundSource(ItemSource): - sources: Tuple[ItemSource, ...] = () - - -class Tag(ItemSource): +class Tag(Source): """Not a real source, just a way to add tags to an item. Will be removed from the item sources during unpacking.""" tag: Tuple[ItemTag, ...] @@ -69,10 +64,10 @@ class Tag(ItemSource): @dataclass(frozen=True) class GameItem: name: str - sources: List[ItemSource] = field(default_factory=list) + sources: List[Source] = field(default_factory=list) tags: Set[ItemTag] = field(default_factory=set) - def add_sources(self, sources: Iterable[ItemSource]): + def add_sources(self, sources: Iterable[Source]): self.sources.extend(source for source in sources if type(source) is not Tag) for source in sources: self.add_tags(source.add_tags) diff --git a/worlds/stardew_valley/data/harvest.py b/worlds/stardew_valley/data/harvest.py index 0fdae95495..621288ec4b 100644 --- a/worlds/stardew_valley/data/harvest.py +++ b/worlds/stardew_valley/data/harvest.py @@ -1,18 +1,18 @@ from dataclasses import dataclass from typing import Tuple, Sequence, Mapping -from .game_item import ItemSource, ItemTag +from .game_item import Source, ItemTag from ..strings.season_names import Season @dataclass(frozen=True, kw_only=True) -class ForagingSource(ItemSource): +class ForagingSource(Source): regions: Tuple[str, ...] seasons: Tuple[str, ...] = Season.all @dataclass(frozen=True, kw_only=True) -class SeasonalForagingSource(ItemSource): +class SeasonalForagingSource(Source): season: str days: Sequence[int] regions: Tuple[str, ...] @@ -22,17 +22,17 @@ class SeasonalForagingSource(ItemSource): @dataclass(frozen=True, kw_only=True) -class FruitBatsSource(ItemSource): +class FruitBatsSource(Source): ... @dataclass(frozen=True, kw_only=True) -class MushroomCaveSource(ItemSource): +class MushroomCaveSource(Source): ... @dataclass(frozen=True, kw_only=True) -class HarvestFruitTreeSource(ItemSource): +class HarvestFruitTreeSource(Source): add_tags = (ItemTag.CROPSANITY,) sapling: str @@ -46,7 +46,7 @@ class HarvestFruitTreeSource(ItemSource): @dataclass(frozen=True, kw_only=True) -class HarvestCropSource(ItemSource): +class HarvestCropSource(Source): add_tags = (ItemTag.CROPSANITY,) seed: str @@ -61,5 +61,5 @@ class HarvestCropSource(ItemSource): @dataclass(frozen=True, kw_only=True) -class ArtifactSpotSource(ItemSource): +class ArtifactSpotSource(Source): amount: int diff --git a/worlds/stardew_valley/data/items.csv b/worlds/stardew_valley/data/items.csv index 36e048100c..44e16c5d50 100644 --- a/worlds/stardew_valley/data/items.csv +++ b/worlds/stardew_valley/data/items.csv @@ -509,6 +509,7 @@ id,name,classification,groups,mod_name 561,Fishing Bar Size Bonus,filler,PLAYER_BUFF, 562,Quality Bonus,filler,PLAYER_BUFF, 563,Glow Bonus,filler,PLAYER_BUFF, +564,Pet Bowl,progression,BUILDING, 4001,Burnt Trap,trap,TRAP, 4002,Darkness Trap,trap,TRAP, 4003,Frozen Trap,trap,TRAP, diff --git a/worlds/stardew_valley/data/requirement.py b/worlds/stardew_valley/data/requirement.py index b2416d8d0b..d335527a39 100644 --- a/worlds/stardew_valley/data/requirement.py +++ b/worlds/stardew_valley/data/requirement.py @@ -21,6 +21,11 @@ class SkillRequirement(Requirement): level: int +@dataclass(frozen=True) +class RegionRequirement(Requirement): + region: str + + @dataclass(frozen=True) class SeasonRequirement(Requirement): season: str diff --git a/worlds/stardew_valley/data/shop.py b/worlds/stardew_valley/data/shop.py index cc9506023f..3700ee8978 100644 --- a/worlds/stardew_valley/data/shop.py +++ b/worlds/stardew_valley/data/shop.py @@ -1,14 +1,14 @@ from dataclasses import dataclass from typing import Tuple, Optional -from .game_item import ItemSource +from .game_item import Source from ..strings.season_names import Season ItemPrice = Tuple[int, str] @dataclass(frozen=True, kw_only=True) -class ShopSource(ItemSource): +class ShopSource(Source): shop_region: str money_price: Optional[int] = None items_price: Optional[Tuple[ItemPrice, ...]] = None @@ -20,20 +20,20 @@ class ShopSource(ItemSource): @dataclass(frozen=True, kw_only=True) -class MysteryBoxSource(ItemSource): +class MysteryBoxSource(Source): amount: int @dataclass(frozen=True, kw_only=True) -class ArtifactTroveSource(ItemSource): +class ArtifactTroveSource(Source): amount: int @dataclass(frozen=True, kw_only=True) -class PrizeMachineSource(ItemSource): +class PrizeMachineSource(Source): amount: int @dataclass(frozen=True, kw_only=True) -class FishingTreasureChestSource(ItemSource): +class FishingTreasureChestSource(Source): amount: int diff --git a/worlds/stardew_valley/early_items.py b/worlds/stardew_valley/early_items.py index 1457c5c7c5..550a92b445 100644 --- a/worlds/stardew_valley/early_items.py +++ b/worlds/stardew_valley/early_items.py @@ -23,9 +23,9 @@ def setup_early_items(multiworld, options: stardew_options.StardewValleyOptions, add_seasonal_candidates(early_candidates, options) - if options.building_progression & stardew_options.BuildingProgression.option_progressive: + if content.features.building_progression.is_progressive: early_forced.append(Building.shipping_bin) - if options.farm_type != stardew_options.FarmType.option_meadowlands: + if Building.coop not in content.features.building_progression.starting_buildings: early_candidates.append("Progressive Coop") early_candidates.append("Progressive Barn") diff --git a/worlds/stardew_valley/items.py b/worlds/stardew_valley/items.py index dcb37a8f41..b4b1175c1d 100644 --- a/worlds/stardew_valley/items.py +++ b/worlds/stardew_valley/items.py @@ -15,7 +15,7 @@ from .data.game_item import ItemTag from .logic.logic_event import all_events from .mods.mod_data import ModNames from .options import StardewValleyOptions, TrapItems, FestivalLocations, ExcludeGingerIsland, SpecialOrderLocations, SeasonRandomization, Museumsanity, \ - BuildingProgression, ElevatorProgression, BackpackProgression, ArcadeMachineLocations, Monstersanity, Goal, \ + ElevatorProgression, BackpackProgression, ArcadeMachineLocations, Monstersanity, Goal, \ Chefsanity, Craftsanity, BundleRandomization, EntranceRandomization, Shipsanity, Walnutsanity, EnabledFillerBuffs from .strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName from .strings.ap_names.ap_weapon_names import APWeapon @@ -225,7 +225,7 @@ def create_unique_items(item_factory: StardewItemFactory, options: StardewValley create_tools(item_factory, content, items) create_skills(item_factory, content, items) create_wizard_buildings(item_factory, options, items) - create_carpenter_buildings(item_factory, options, items) + create_carpenter_buildings(item_factory, content, items) items.append(item_factory("Railroad Boulder Removed")) items.append(item_factory(CommunityUpgrade.fruit_bats)) items.append(item_factory(CommunityUpgrade.mushroom_boxes)) @@ -353,30 +353,14 @@ def create_wizard_buildings(item_factory: StardewItemFactory, options: StardewVa items.append(item_factory("Woods Obelisk")) -def create_carpenter_buildings(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]): - building_option = options.building_progression - if not building_option & BuildingProgression.option_progressive: +def create_carpenter_buildings(item_factory: StardewItemFactory, content: StardewContent, items: List[Item]): + building_progression = content.features.building_progression + if not building_progression.is_progressive: return - items.append(item_factory("Progressive Coop")) - items.append(item_factory("Progressive Coop")) - items.append(item_factory("Progressive Coop")) - items.append(item_factory("Progressive Barn")) - items.append(item_factory("Progressive Barn")) - items.append(item_factory("Progressive Barn")) - items.append(item_factory("Well")) - items.append(item_factory("Silo")) - items.append(item_factory("Mill")) - items.append(item_factory("Progressive Shed")) - items.append(item_factory("Progressive Shed", ItemClassification.useful)) - items.append(item_factory("Fish Pond")) - items.append(item_factory("Stable")) - items.append(item_factory("Slime Hutch")) - items.append(item_factory("Shipping Bin")) - items.append(item_factory("Progressive House")) - items.append(item_factory("Progressive House")) - items.append(item_factory("Progressive House")) - if ModNames.tractor in options.mods: - items.append(item_factory("Tractor Garage")) + + for building in content.farm_buildings.values(): + item_name, _ = building_progression.to_progressive_item(building.name) + items.append(item_factory(item_name)) def create_quest_rewards(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]): diff --git a/worlds/stardew_valley/locations.py b/worlds/stardew_valley/locations.py index c7d787e55d..0d621fda49 100644 --- a/worlds/stardew_valley/locations.py +++ b/worlds/stardew_valley/locations.py @@ -11,7 +11,7 @@ from .data.game_item import ItemTag from .data.museum_data import all_museum_items from .mods.mod_data import ModNames from .options import ExcludeGingerIsland, ArcadeMachineLocations, SpecialOrderLocations, Museumsanity, \ - FestivalLocations, BuildingProgression, ElevatorProgression, BackpackProgression, FarmType + FestivalLocations, ElevatorProgression, BackpackProgression, FarmType from .options import StardewValleyOptions, Craftsanity, Chefsanity, Cooksanity, Shipsanity, Monstersanity from .strings.goal_names import Goal from .strings.quest_names import ModQuest, Quest @@ -261,6 +261,19 @@ def extend_baby_locations(randomized_locations: List[LocationData]): randomized_locations.extend(baby_locations) +def extend_building_locations(randomized_locations: List[LocationData], content: StardewContent): + building_progression = content.features.building_progression + if not building_progression.is_progressive: + return + + for building in content.farm_buildings.values(): + if building.name in building_progression.starting_buildings: + continue + + location_name = building_progression.to_location_name(building.name) + randomized_locations.append(location_table[location_name]) + + def extend_festival_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, random: Random): if options.festival_locations == FestivalLocations.option_disabled: return @@ -485,10 +498,7 @@ def create_locations(location_collector: StardewLocationCollector, if skill_progression.is_mastery_randomized(skill): randomized_locations.append(location_table[skill.mastery_name]) - if options.building_progression & BuildingProgression.option_progressive: - for location in locations_by_tag[LocationTags.BUILDING_BLUEPRINT]: - if location.mod_name is None or location.mod_name in options.mods: - randomized_locations.append(location_table[location.name]) + extend_building_locations(randomized_locations, content) if options.arcade_machine_locations != ArcadeMachineLocations.option_disabled: randomized_locations.extend(locations_by_tag[LocationTags.ARCADE_MACHINE_VICTORY]) diff --git a/worlds/stardew_valley/logic/base_logic.py b/worlds/stardew_valley/logic/base_logic.py index 7b377fce1f..761ee54157 100644 --- a/worlds/stardew_valley/logic/base_logic.py +++ b/worlds/stardew_valley/logic/base_logic.py @@ -20,7 +20,6 @@ class LogicRegistry: self.museum_rules: Dict[str, StardewRule] = {} self.festival_rules: Dict[str, StardewRule] = {} self.quest_rules: Dict[str, StardewRule] = {} - self.building_rules: Dict[str, StardewRule] = {} self.special_order_rules: Dict[str, StardewRule] = {} self.sve_location_rules: Dict[str, StardewRule] = {} diff --git a/worlds/stardew_valley/logic/building_logic.py b/worlds/stardew_valley/logic/building_logic.py index b4eff43993..58a375d046 100644 --- a/worlds/stardew_valley/logic/building_logic.py +++ b/worlds/stardew_valley/logic/building_logic.py @@ -1,22 +1,22 @@ +import typing from functools import cached_property -from typing import Dict, Union +from typing import Union from Utils import cache_self1 from .base_logic import BaseLogic, BaseLogicMixin from .has_logic import HasLogicMixin -from .money_logic import MoneyLogicMixin from .received_logic import ReceivedLogicMixin from .region_logic import RegionLogicMixin -from ..options import BuildingProgression -from ..stardew_rule import StardewRule, True_, False_, Has -from ..strings.artisan_good_names import ArtisanGood +from ..stardew_rule import StardewRule, true_ from ..strings.building_names import Building -from ..strings.fish_names import WaterItem -from ..strings.material_names import Material -from ..strings.metal_names import MetalBar from ..strings.region_names import Region -has_group = "building" +if typing.TYPE_CHECKING: + from .source_logic import SourceLogicMixin +else: + SourceLogicMixin = object + +AUTO_BUILDING_BUILDINGS = {Building.shipping_bin, Building.pet_bowl, Building.farm_house} class BuildingLogicMixin(BaseLogicMixin): @@ -25,78 +25,38 @@ class BuildingLogicMixin(BaseLogicMixin): self.building = BuildingLogic(*args, **kwargs) -class BuildingLogic(BaseLogic[Union[BuildingLogicMixin, MoneyLogicMixin, RegionLogicMixin, ReceivedLogicMixin, HasLogicMixin]]): - def initialize_rules(self): - self.registry.building_rules.update({ - # @formatter:off - Building.barn: self.logic.money.can_spend(6000) & self.logic.has_all(Material.wood, Material.stone), - Building.big_barn: self.logic.money.can_spend(12000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.barn), - Building.deluxe_barn: self.logic.money.can_spend(25000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.big_barn), - Building.coop: self.logic.money.can_spend(4000) & self.logic.has_all(Material.wood, Material.stone), - Building.big_coop: self.logic.money.can_spend(10000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.coop), - Building.deluxe_coop: self.logic.money.can_spend(20000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.big_coop), - Building.fish_pond: self.logic.money.can_spend(5000) & self.logic.has_all(Material.stone, WaterItem.seaweed, WaterItem.green_algae), - Building.mill: self.logic.money.can_spend(2500) & self.logic.has_all(Material.stone, Material.wood, ArtisanGood.cloth), - Building.shed: self.logic.money.can_spend(15000) & self.logic.has(Material.wood), - Building.big_shed: self.logic.money.can_spend(20000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.shed), - Building.silo: self.logic.money.can_spend(100) & self.logic.has_all(Material.stone, Material.clay, MetalBar.copper), - Building.slime_hutch: self.logic.money.can_spend(10000) & self.logic.has_all(Material.stone, MetalBar.quartz, MetalBar.iridium), - Building.stable: self.logic.money.can_spend(10000) & self.logic.has_all(Material.hardwood, MetalBar.iron), - Building.well: self.logic.money.can_spend(1000) & self.logic.has(Material.stone), - Building.shipping_bin: self.logic.money.can_spend(250) & self.logic.has(Material.wood), - Building.kitchen: self.logic.money.can_spend(10000) & self.logic.has(Material.wood) & self.logic.building.has_house(0), - Building.kids_room: self.logic.money.can_spend(65000) & self.logic.has(Material.hardwood) & self.logic.building.has_house(1), - Building.cellar: self.logic.money.can_spend(100000) & self.logic.building.has_house(2), - # @formatter:on - }) - - def update_rules(self, new_rules: Dict[str, StardewRule]): - self.registry.building_rules.update(new_rules) +class BuildingLogic(BaseLogic[Union[BuildingLogicMixin, RegionLogicMixin, ReceivedLogicMixin, HasLogicMixin, SourceLogicMixin]]): @cache_self1 - def has_building(self, building: str) -> StardewRule: - # Shipping bin is special. The mod auto-builds it when received, no need to go to Robin. - if building is Building.shipping_bin: - if not self.options.building_progression & BuildingProgression.option_progressive: - return True_() - return self.logic.received(building) + def can_build(self, building_name: str) -> StardewRule: + building = self.content.farm_buildings.get(building_name) + assert building is not None, f"Building {building_name} not found." + + source_rule = self.logic.source.has_access_to_any(building.sources) + if not building.is_upgrade: + return source_rule + + upgrade_rule = self.logic.building.has_building(building.upgrade_from) + return self.logic.and_(upgrade_rule, source_rule) + + @cache_self1 + def has_building(self, building_name: str) -> StardewRule: + building_progression = self.content.features.building_progression + + if building_name in building_progression.starting_buildings: + return true_ + + if not building_progression.is_progressive: + return self.logic.building.can_build(building_name) + + # Those buildings are special. The mod auto-builds them when received, no need to go to Robin. + if building_name in AUTO_BUILDING_BUILDINGS: + return self.logic.received(Building.shipping_bin) carpenter_rule = self.logic.building.can_construct_buildings - if not self.options.building_progression & BuildingProgression.option_progressive: - return Has(building, self.registry.building_rules, has_group) & carpenter_rule - - count = 1 - if building in [Building.coop, Building.barn, Building.shed]: - building = f"Progressive {building}" - elif building.startswith("Big"): - count = 2 - building = " ".join(["Progressive", *building.split(" ")[1:]]) - elif building.startswith("Deluxe"): - count = 3 - building = " ".join(["Progressive", *building.split(" ")[1:]]) - return self.logic.received(building, count) & carpenter_rule + item, count = building_progression.to_progressive_item(building_name) + return self.logic.received(item, count) & carpenter_rule @cached_property def can_construct_buildings(self) -> StardewRule: return self.logic.region.can_reach(Region.carpenter) - - @cache_self1 - def has_house(self, upgrade_level: int) -> StardewRule: - if upgrade_level < 1: - return True_() - - if upgrade_level > 3: - return False_() - - carpenter_rule = self.logic.building.can_construct_buildings - if self.options.building_progression & BuildingProgression.option_progressive: - return carpenter_rule & self.logic.received(f"Progressive House", upgrade_level) - - if upgrade_level == 1: - return carpenter_rule & Has(Building.kitchen, self.registry.building_rules, has_group) - - if upgrade_level == 2: - return carpenter_rule & Has(Building.kids_room, self.registry.building_rules, has_group) - - # if upgrade_level == 3: - return carpenter_rule & Has(Building.cellar, self.registry.building_rules, has_group) diff --git a/worlds/stardew_valley/logic/cooking_logic.py b/worlds/stardew_valley/logic/cooking_logic.py index 8c8f716afb..339b2b9817 100644 --- a/worlds/stardew_valley/logic/cooking_logic.py +++ b/worlds/stardew_valley/logic/cooking_logic.py @@ -17,6 +17,7 @@ from ..data.recipe_data import RecipeSource, StarterSource, ShopSource, SkillSou from ..data.recipe_source import CutsceneSource, ShopTradeSource from ..options import Chefsanity from ..stardew_rule import StardewRule, True_, False_ +from ..strings.building_names import Building from ..strings.region_names import LogicRegion from ..strings.skill_names import Skill from ..strings.tv_channel_names import Channel @@ -32,7 +33,7 @@ class CookingLogic(BaseLogic[Union[HasLogicMixin, ReceivedLogicMixin, RegionLogi BuildingLogicMixin, RelationshipLogicMixin, SkillLogicMixin, CookingLogicMixin]]): @cached_property def can_cook_in_kitchen(self) -> StardewRule: - return self.logic.building.has_house(1) | self.logic.skill.has_level(Skill.foraging, 9) + return self.logic.building.has_building(Building.kitchen) | self.logic.skill.has_level(Skill.foraging, 9) # Should be cached def can_cook(self, recipe: CookingRecipe = None) -> StardewRule: diff --git a/worlds/stardew_valley/logic/goal_logic.py b/worlds/stardew_valley/logic/goal_logic.py index 0ad3eb4f37..6ffa4da15a 100644 --- a/worlds/stardew_valley/logic/goal_logic.py +++ b/worlds/stardew_valley/logic/goal_logic.py @@ -44,7 +44,7 @@ class GoalLogic(BaseLogic[StardewLogic]): self.logic.museum.can_complete_museum(), # Catching every fish not expected # Shipping every item not expected - self.logic.relationship.can_get_married() & self.logic.building.has_house(2), + self.logic.relationship.can_get_married() & self.logic.building.has_building(Building.kids_room), self.logic.relationship.has_hearts_with_n(5, 8), # 5 Friends self.logic.relationship.has_hearts_with_n(10, 8), # 10 friends self.logic.pet.has_pet_hearts(5), # Max Pet diff --git a/worlds/stardew_valley/logic/grind_logic.py b/worlds/stardew_valley/logic/grind_logic.py index 997300ae7a..9550a12830 100644 --- a/worlds/stardew_valley/logic/grind_logic.py +++ b/worlds/stardew_valley/logic/grind_logic.py @@ -13,6 +13,7 @@ from ..strings.craftable_names import Consumable from ..strings.currency_names import Currency from ..strings.fish_names import WaterChest from ..strings.geode_names import Geode +from ..strings.material_names import Material from ..strings.region_names import Region from ..strings.tool_names import Tool @@ -21,9 +22,14 @@ if TYPE_CHECKING: else: ToolLogicMixin = object -MIN_ITEMS = 10 -MAX_ITEMS = 999 -PERCENT_REQUIRED_FOR_MAX_ITEM = 24 +MIN_MEDIUM_ITEMS = 10 +MAX_MEDIUM_ITEMS = 999 +PERCENT_REQUIRED_FOR_MAX_MEDIUM_ITEM = 24 + +EASY_ITEMS = {Material.wood, Material.stone, Material.fiber, Material.sap} +MIN_EASY_ITEMS = 300 +MAX_EASY_ITEMS = 2997 +PERCENT_REQUIRED_FOR_MAX_EASY_ITEM = 6 class GrindLogicMixin(BaseLogicMixin): @@ -43,7 +49,7 @@ class GrindLogic(BaseLogic[Union[GrindLogicMixin, HasLogicMixin, ReceivedLogicMi # Assuming one box per day, but halved because we don't know how many months have passed before Mr. Qi's Plane Ride. time_rule = self.logic.time.has_lived_months(quantity // 14) return self.logic.and_(opening_rule, mystery_box_rule, - book_of_mysteries_rule, time_rule,) + book_of_mysteries_rule, time_rule, ) def can_grind_artifact_troves(self, quantity: int) -> StardewRule: opening_rule = self.logic.region.can_reach(Region.blacksmith) @@ -67,11 +73,26 @@ class GrindLogic(BaseLogic[Union[GrindLogicMixin, HasLogicMixin, ReceivedLogicMi # Assuming twelve per month if the player does not grind it. self.logic.time.has_lived_months(quantity // 12)) + def can_grind_item(self, quantity: int, item: str | None = None) -> StardewRule: + if item in EASY_ITEMS: + return self.logic.grind.can_grind_easy_item(quantity) + else: + return self.logic.grind.can_grind_medium_item(quantity) + @cache_self1 - def can_grind_item(self, quantity: int) -> StardewRule: - if quantity <= MIN_ITEMS: + def can_grind_medium_item(self, quantity: int) -> StardewRule: + if quantity <= MIN_MEDIUM_ITEMS: return self.logic.true_ - quantity = min(quantity, MAX_ITEMS) - price = max(1, quantity * PERCENT_REQUIRED_FOR_MAX_ITEM // MAX_ITEMS) + quantity = min(quantity, MAX_MEDIUM_ITEMS) + price = max(1, quantity * PERCENT_REQUIRED_FOR_MAX_MEDIUM_ITEM // MAX_MEDIUM_ITEMS) + return HasProgressionPercent(self.player, price) + + @cache_self1 + def can_grind_easy_item(self, quantity: int) -> StardewRule: + if quantity <= MIN_EASY_ITEMS: + return self.logic.true_ + + quantity = min(quantity, MAX_EASY_ITEMS) + price = max(1, quantity * PERCENT_REQUIRED_FOR_MAX_EASY_ITEM // MAX_EASY_ITEMS) return HasProgressionPercent(self.player, price) diff --git a/worlds/stardew_valley/logic/logic.py b/worlds/stardew_valley/logic/logic.py index dd95165417..aa4cd075d3 100644 --- a/worlds/stardew_valley/logic/logic.py +++ b/worlds/stardew_valley/logic/logic.py @@ -254,7 +254,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin Geode.omni: self.mine.can_mine_in_the_mines_floor_41_80() | self.region.can_reach(Region.desert) | self.tool.has_tool(Tool.pan, ToolMaterial.iron) | self.received(Wallet.rusty_key) | (self.has(Fish.octopus) & self.building.has_building(Building.fish_pond)) | self.region.can_reach(Region.volcano_floor_10), Gift.bouquet: self.relationship.has_hearts_with_any_bachelor(8) & self.money.can_spend_at(Region.pierre_store, 100), Gift.golden_pumpkin: self.season.has(Season.fall) | self.action.can_open_geode(Geode.artifact_trove), - Gift.mermaid_pendant: self.region.can_reach(Region.tide_pools) & self.relationship.has_hearts_with_any_bachelor(10) & self.building.has_house(1) & self.has(Consumable.rain_totem), + Gift.mermaid_pendant: self.region.can_reach(Region.tide_pools) & self.relationship.has_hearts_with_any_bachelor(10) & self.building.has_building(Building.kitchen) & self.has(Consumable.rain_totem), Gift.movie_ticket: self.money.can_spend_at(Region.movie_ticket_stand, 1000), Gift.pearl: (self.has(Fish.blobfish) & self.building.has_building(Building.fish_pond)) | self.action.can_open_geode(Geode.artifact_trove), Gift.tea_set: self.season.has(Season.winter) & self.time.has_lived_max_months, @@ -355,9 +355,6 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin obtention_rule = self.registry.item_rules[recipe] if recipe in self.registry.item_rules else False_() self.registry.item_rules[recipe] = obtention_rule | crafting_rule - self.building.initialize_rules() - self.building.update_rules(self.mod.building.get_modded_building_rules()) - self.quest.initialize_rules() self.quest.update_rules(self.mod.quest.get_modded_quest_rules()) diff --git a/worlds/stardew_valley/logic/money_logic.py b/worlds/stardew_valley/logic/money_logic.py index 85370273c9..e272436fd8 100644 --- a/worlds/stardew_valley/logic/money_logic.py +++ b/worlds/stardew_valley/logic/money_logic.py @@ -17,8 +17,8 @@ from ..strings.region_names import Region, LogicRegion if typing.TYPE_CHECKING: from .shipping_logic import ShippingLogicMixin - - assert ShippingLogicMixin +else: + ShippingLogicMixin = object qi_gem_rewards = ("100 Qi Gems", "50 Qi Gems", "40 Qi Gems", "35 Qi Gems", "25 Qi Gems", "20 Qi Gems", "15 Qi Gems", "10 Qi Gems") @@ -31,7 +31,7 @@ class MoneyLogicMixin(BaseLogicMixin): class MoneyLogic(BaseLogic[Union[RegionLogicMixin, MoneyLogicMixin, TimeLogicMixin, RegionLogicMixin, ReceivedLogicMixin, HasLogicMixin, SeasonLogicMixin, -GrindLogicMixin, 'ShippingLogicMixin']]): +GrindLogicMixin, ShippingLogicMixin]]): @cache_self1 def can_have_earned_total(self, amount: int) -> StardewRule: @@ -80,7 +80,7 @@ GrindLogicMixin, 'ShippingLogicMixin']]): item_rules = [] if source.items_price is not None: for price, item in source.items_price: - item_rules.append(self.logic.has(item) & self.logic.grind.can_grind_item(price)) + item_rules.append(self.logic.has(item) & self.logic.grind.can_grind_item(price, item)) region_rule = self.logic.region.can_reach(source.shop_region) diff --git a/worlds/stardew_valley/logic/relationship_logic.py b/worlds/stardew_valley/logic/relationship_logic.py index b74bdc5645..2de82bf972 100644 --- a/worlds/stardew_valley/logic/relationship_logic.py +++ b/worlds/stardew_valley/logic/relationship_logic.py @@ -15,9 +15,9 @@ from ..content.feature import friendsanity from ..data.villagers_data import Villager from ..stardew_rule import StardewRule, True_, false_, true_ from ..strings.ap_names.mods.mod_items import SVEQuestItem +from ..strings.building_names import Building from ..strings.generic_names import Generic from ..strings.gift_names import Gift -from ..strings.quest_names import ModQuest from ..strings.region_names import Region from ..strings.season_names import Season from ..strings.villager_names import NPC, ModNPC @@ -63,7 +63,7 @@ ReceivedLogicMixin, HasLogicMixin, ModLogicMixin]]): if not self.content.features.friendsanity.is_enabled: return self.logic.relationship.can_reproduce(number_children) - return self.logic.received_n(*possible_kids, count=number_children) & self.logic.building.has_house(2) + return self.logic.received_n(*possible_kids, count=number_children) & self.logic.building.has_building(Building.kids_room) def can_reproduce(self, number_children: int = 1) -> StardewRule: assert number_children >= 0, "Can't have a negative amount of children." @@ -71,7 +71,7 @@ ReceivedLogicMixin, HasLogicMixin, ModLogicMixin]]): return True_() baby_rules = [self.logic.relationship.can_get_married(), - self.logic.building.has_house(2), + self.logic.building.has_building(Building.kids_room), self.logic.relationship.has_hearts_with_any_bachelor(12), self.logic.relationship.has_children(number_children - 1)] diff --git a/worlds/stardew_valley/logic/requirement_logic.py b/worlds/stardew_valley/logic/requirement_logic.py index 6a5adf4890..3e83950d54 100644 --- a/worlds/stardew_valley/logic/requirement_logic.py +++ b/worlds/stardew_valley/logic/requirement_logic.py @@ -8,6 +8,7 @@ from .fishing_logic import FishingLogicMixin from .has_logic import HasLogicMixin from .quest_logic import QuestLogicMixin from .received_logic import ReceivedLogicMixin +from .region_logic import RegionLogicMixin from .relationship_logic import RelationshipLogicMixin from .season_logic import SeasonLogicMixin from .skill_logic import SkillLogicMixin @@ -16,7 +17,7 @@ from .tool_logic import ToolLogicMixin from .walnut_logic import WalnutLogicMixin from ..data.game_item import Requirement from ..data.requirement import ToolRequirement, BookRequirement, SkillRequirement, SeasonRequirement, YearRequirement, CombatRequirement, QuestRequirement, \ - RelationshipRequirement, FishingRequirement, WalnutRequirement + RelationshipRequirement, FishingRequirement, WalnutRequirement, RegionRequirement class RequirementLogicMixin(BaseLogicMixin): @@ -26,7 +27,7 @@ class RequirementLogicMixin(BaseLogicMixin): class RequirementLogic(BaseLogic[Union[RequirementLogicMixin, HasLogicMixin, ReceivedLogicMixin, ToolLogicMixin, SkillLogicMixin, BookLogicMixin, -SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, RelationshipLogicMixin, FishingLogicMixin, WalnutLogicMixin]]): +SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, RelationshipLogicMixin, FishingLogicMixin, WalnutLogicMixin, RegionLogicMixin]]): def meet_all_requirements(self, requirements: Iterable[Requirement]): if not requirements: @@ -45,6 +46,10 @@ SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, Relationshi def _(self, requirement: SkillRequirement): return self.logic.skill.has_level(requirement.skill, requirement.level) + @meet_requirement.register + def _(self, requirement: RegionRequirement): + return self.logic.region.can_reach(requirement.region) + @meet_requirement.register def _(self, requirement: BookRequirement): return self.logic.book.has_book_power(requirement.book) @@ -76,5 +81,3 @@ SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, Relationshi @meet_requirement.register def _(self, requirement: FishingRequirement): return self.logic.fishing.can_fish_at(requirement.region) - - diff --git a/worlds/stardew_valley/logic/source_logic.py b/worlds/stardew_valley/logic/source_logic.py index 9ef68a020e..f1c6fe3d7b 100644 --- a/worlds/stardew_valley/logic/source_logic.py +++ b/worlds/stardew_valley/logic/source_logic.py @@ -12,7 +12,7 @@ from .region_logic import RegionLogicMixin from .requirement_logic import RequirementLogicMixin from .tool_logic import ToolLogicMixin from ..data.artisan import MachineSource -from ..data.game_item import GenericSource, ItemSource, GameItem, CustomRuleSource, CompoundSource +from ..data.game_item import GenericSource, Source, GameItem, CustomRuleSource from ..data.harvest import ForagingSource, FruitBatsSource, MushroomCaveSource, SeasonalForagingSource, \ HarvestCropSource, HarvestFruitTreeSource, ArtifactSpotSource from ..data.shop import ShopSource, MysteryBoxSource, ArtifactTroveSource, PrizeMachineSource, FishingTreasureChestSource @@ -25,7 +25,7 @@ class SourceLogicMixin(BaseLogicMixin): class SourceLogic(BaseLogic[Union[SourceLogicMixin, HasLogicMixin, ReceivedLogicMixin, HarvestingLogicMixin, MoneyLogicMixin, RegionLogicMixin, - ArtisanLogicMixin, ToolLogicMixin, RequirementLogicMixin, GrindLogicMixin]]): +ArtisanLogicMixin, ToolLogicMixin, RequirementLogicMixin, GrindLogicMixin]]): def has_access_to_item(self, item: GameItem): rules = [] @@ -36,14 +36,10 @@ class SourceLogic(BaseLogic[Union[SourceLogicMixin, HasLogicMixin, ReceivedLogic rules.append(self.logic.source.has_access_to_any(item.sources)) return self.logic.and_(*rules) - def has_access_to_any(self, sources: Iterable[ItemSource]): + def has_access_to_any(self, sources: Iterable[Source]): return self.logic.or_(*(self.logic.source.has_access_to(source) & self.logic.requirement.meet_all_requirements(source.other_requirements) for source in sources)) - def has_access_to_all(self, sources: Iterable[ItemSource]): - return self.logic.and_(*(self.logic.source.has_access_to(source) & self.logic.requirement.meet_all_requirements(source.other_requirements) - for source in sources)) - @functools.singledispatchmethod def has_access_to(self, source: Any): raise ValueError(f"Sources of type{type(source)} have no rule registered.") @@ -56,10 +52,6 @@ class SourceLogic(BaseLogic[Union[SourceLogicMixin, HasLogicMixin, ReceivedLogic def _(self, source: CustomRuleSource): return source.create_rule(self.logic) - @has_access_to.register - def _(self, source: CompoundSource): - return self.logic.source.has_access_to_all(source.sources) - @has_access_to.register def _(self, source: ForagingSource): return self.logic.harvesting.can_forage_from(source) diff --git a/worlds/stardew_valley/mods/logic/buildings_logic.py b/worlds/stardew_valley/mods/logic/buildings_logic.py deleted file mode 100644 index 388204a476..0000000000 --- a/worlds/stardew_valley/mods/logic/buildings_logic.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Dict, Union - -from ..mod_data import ModNames -from ...logic.base_logic import BaseLogicMixin, BaseLogic -from ...logic.has_logic import HasLogicMixin -from ...logic.money_logic import MoneyLogicMixin -from ...stardew_rule import StardewRule -from ...strings.artisan_good_names import ArtisanGood -from ...strings.building_names import ModBuilding -from ...strings.metal_names import MetalBar -from ...strings.region_names import Region - - -class ModBuildingLogicMixin(BaseLogicMixin): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.building = ModBuildingLogic(*args, **kwargs) - - -class ModBuildingLogic(BaseLogic[Union[MoneyLogicMixin, HasLogicMixin]]): - - def get_modded_building_rules(self) -> Dict[str, StardewRule]: - buildings = dict() - if ModNames.tractor in self.options.mods: - tractor_rule = (self.logic.money.can_spend_at(Region.carpenter, 150000) & - self.logic.has_all(MetalBar.iron, MetalBar.iridium, ArtisanGood.battery_pack)) - buildings.update({ModBuilding.tractor_garage: tractor_rule}) - return buildings diff --git a/worlds/stardew_valley/mods/logic/mod_logic.py b/worlds/stardew_valley/mods/logic/mod_logic.py index 37c17183db..ff28108b7d 100644 --- a/worlds/stardew_valley/mods/logic/mod_logic.py +++ b/worlds/stardew_valley/mods/logic/mod_logic.py @@ -1,4 +1,3 @@ -from .buildings_logic import ModBuildingLogicMixin from .deepwoods_logic import DeepWoodsLogicMixin from .elevator_logic import ModElevatorLogicMixin from .item_logic import ModItemLogicMixin @@ -16,6 +15,6 @@ class ModLogicMixin(BaseLogicMixin): self.mod = ModLogic(*args, **kwargs) -class ModLogic(ModElevatorLogicMixin, MagicLogicMixin, ModSkillLogicMixin, ModItemLogicMixin, ModQuestLogicMixin, ModBuildingLogicMixin, +class ModLogic(ModElevatorLogicMixin, MagicLogicMixin, ModSkillLogicMixin, ModItemLogicMixin, ModQuestLogicMixin, ModSpecialOrderLogicMixin, DeepWoodsLogicMixin, SVELogicMixin): pass diff --git a/worlds/stardew_valley/rules.py b/worlds/stardew_valley/rules.py index dc63018697..bdfbc20488 100644 --- a/worlds/stardew_valley/rules.py +++ b/worlds/stardew_valley/rules.py @@ -19,7 +19,7 @@ from .logic.logic import StardewLogic from .logic.time_logic import MAX_MONTHS from .logic.tool_logic import tool_upgrade_prices from .mods.mod_data import ModNames -from .options import BuildingProgression, ExcludeGingerIsland, SpecialOrderLocations, Museumsanity, BackpackProgression, Shipsanity, \ +from .options import ExcludeGingerIsland, SpecialOrderLocations, Museumsanity, BackpackProgression, Shipsanity, \ Monstersanity, Chefsanity, Craftsanity, ArcadeMachineLocations, Cooksanity, StardewValleyOptions, Walnutsanity from .stardew_rule import And, StardewRule, true_ from .stardew_rule.indirect_connection import look_for_indirect_connection @@ -71,7 +71,7 @@ def set_rules(world): set_tool_rules(logic, multiworld, player, world_content) set_skills_rules(logic, multiworld, player, world_content) set_bundle_rules(bundle_rooms, logic, multiworld, player, world_options) - set_building_rules(logic, multiworld, player, world_options) + set_building_rules(logic, multiworld, player, world_content) set_cropsanity_rules(logic, multiworld, player, world_content) set_story_quests_rules(all_location_names, logic, multiworld, player, world_options) set_special_order_rules(all_location_names, logic, multiworld, player, world_options) @@ -130,15 +130,19 @@ def set_tool_rules(logic: StardewLogic, multiworld, player, content: StardewCont MultiWorldRules.set_rule(tool_upgrade_location, logic.tool.has_tool(tool, previous)) -def set_building_rules(logic: StardewLogic, multiworld, player, world_options: StardewValleyOptions): - if not world_options.building_progression & BuildingProgression.option_progressive: +def set_building_rules(logic: StardewLogic, multiworld, player, content: StardewContent): + building_progression = content.features.building_progression + if not building_progression.is_progressive: return - for building in locations.locations_by_tag[LocationTags.BUILDING_BLUEPRINT]: - if building.mod_name is not None and building.mod_name not in world_options.mods: + for building in content.farm_buildings.values(): + if building.name in building_progression.starting_buildings: continue - MultiWorldRules.set_rule(multiworld.get_location(building.name, player), - logic.registry.building_rules[building.name.replace(" Blueprint", "")]) + + location_name = building_progression.to_location_name(building.name) + + MultiWorldRules.set_rule(multiworld.get_location(location_name, player), + logic.building.can_build(building.name)) def set_bundle_rules(bundle_rooms: List[BundleRoom], logic: StardewLogic, multiworld, player, world_options: StardewValleyOptions): @@ -241,7 +245,7 @@ def set_dangerous_mine_rules(logic, multiworld, player, world_options: StardewVa def set_farm_buildings_entrance_rules(logic, multiworld, player): - set_entrance_rule(multiworld, player, Entrance.downstairs_to_cellar, logic.building.has_house(3)) + set_entrance_rule(multiworld, player, Entrance.downstairs_to_cellar, logic.building.has_building(Building.cellar)) set_entrance_rule(multiworld, player, Entrance.use_desert_obelisk, logic.can_use_obelisk(Transportation.desert_obelisk)) set_entrance_rule(multiworld, player, Entrance.enter_greenhouse, logic.received("Greenhouse")) set_entrance_rule(multiworld, player, Entrance.enter_coop, logic.building.has_building(Building.coop)) diff --git a/worlds/stardew_valley/strings/building_names.py b/worlds/stardew_valley/strings/building_names.py index b67808febc..50c84b67ca 100644 --- a/worlds/stardew_valley/strings/building_names.py +++ b/worlds/stardew_valley/strings/building_names.py @@ -14,9 +14,11 @@ class Building: stable = "Stable" well = "Well" shipping_bin = "Shipping Bin" + farm_house = "Farm House" kitchen = "Kitchen" kids_room = "Kids Room" cellar = "Cellar" + pet_bowl = "Pet Bowl" class ModBuilding: diff --git a/worlds/stardew_valley/test/TestBooksanity.py b/worlds/stardew_valley/test/TestBooksanity.py index 3c737e502c..c25924aa3b 100644 --- a/worlds/stardew_valley/test/TestBooksanity.py +++ b/worlds/stardew_valley/test/TestBooksanity.py @@ -61,11 +61,13 @@ class TestBooksanityNone(SVTestBase): for location in self.multiworld.get_locations(): if not location.name.startswith(shipsanity_prefix): continue + item_to_ship = location.name[len(shipsanity_prefix):] if item_to_ship not in power_books and item_to_ship not in skill_books: continue + with self.subTest(location.name): - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location(location) class TestBooksanityPowers(SVTestBase): @@ -107,11 +109,13 @@ class TestBooksanityPowers(SVTestBase): for location in self.multiworld.get_locations(): if not location.name.startswith(shipsanity_prefix): continue + item_to_ship = location.name[len(shipsanity_prefix):] if item_to_ship not in power_books and item_to_ship not in skill_books: continue + with self.subTest(location.name): - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location(location) class TestBooksanityPowersAndSkills(SVTestBase): @@ -153,11 +157,13 @@ class TestBooksanityPowersAndSkills(SVTestBase): for location in self.multiworld.get_locations(): if not location.name.startswith(shipsanity_prefix): continue + item_to_ship = location.name[len(shipsanity_prefix):] if item_to_ship not in power_books and item_to_ship not in skill_books: continue + with self.subTest(location.name): - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location(location) class TestBooksanityAll(SVTestBase): @@ -199,8 +205,10 @@ class TestBooksanityAll(SVTestBase): for location in self.multiworld.get_locations(): if not location.name.startswith(shipsanity_prefix): continue + item_to_ship = location.name[len(shipsanity_prefix):] if item_to_ship not in power_books and item_to_ship not in skill_books: continue + with self.subTest(location.name): - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location(location) diff --git a/worlds/stardew_valley/test/TestCrops.py b/worlds/stardew_valley/test/TestCrops.py index 4fa836a97d..53048259ab 100644 --- a/worlds/stardew_valley/test/TestCrops.py +++ b/worlds/stardew_valley/test/TestCrops.py @@ -1,5 +1,9 @@ from . import SVTestBase from .. import options +from ..strings.ap_names.transport_names import Transportation +from ..strings.building_names import Building +from ..strings.region_names import Region +from ..strings.seed_names import Seed class TestCropsanityRules(SVTestBase): @@ -8,13 +12,13 @@ class TestCropsanityRules(SVTestBase): } def test_need_greenhouse_for_cactus(self): - harvest_cactus = self.world.logic.region.can_reach_location("Harvest Cactus Fruit") - self.assert_rule_false(harvest_cactus, self.multiworld.state) + harvest_cactus_fruit = "Harvest Cactus Fruit" + self.assert_cannot_reach_location(harvest_cactus_fruit) - self.multiworld.state.collect(self.create_item("Cactus Seeds")) - self.multiworld.state.collect(self.create_item("Shipping Bin")) - self.multiworld.state.collect(self.create_item("Desert Obelisk")) - self.assert_rule_false(harvest_cactus, self.multiworld.state) + self.multiworld.state.collect(self.create_item(Seed.cactus)) + self.multiworld.state.collect(self.create_item(Building.shipping_bin)) + self.multiworld.state.collect(self.create_item(Transportation.desert_obelisk)) + self.assert_cannot_reach_location(harvest_cactus_fruit) - self.multiworld.state.collect(self.create_item("Greenhouse")) - self.assert_rule_true(harvest_cactus, self.multiworld.state) + self.multiworld.state.collect(self.create_item(Region.greenhouse)) + self.assert_can_reach_location(harvest_cactus_fruit) diff --git a/worlds/stardew_valley/test/TestFarmType.py b/worlds/stardew_valley/test/TestFarmType.py index f78edc3eec..1bb4404ae6 100644 --- a/worlds/stardew_valley/test/TestFarmType.py +++ b/worlds/stardew_valley/test/TestFarmType.py @@ -1,3 +1,5 @@ +from collections import Counter + from . import SVTestBase from .assertion import WorldAssertMixin from .. import options @@ -5,27 +7,49 @@ from .. import options class TestStartInventoryStandardFarm(WorldAssertMixin, SVTestBase): options = { - options.FarmType.internal_name: options.FarmType.option_standard, + options.FarmType: options.FarmType.option_standard, } def test_start_inventory_progressive_coops(self): - start_items = dict(map(lambda x: (x.name, self.multiworld.precollected_items[self.player].count(x)), self.multiworld.precollected_items[self.player])) - items = dict(map(lambda x: (x.name, self.multiworld.itempool.count(x)), self.multiworld.itempool)) + start_items = Counter((i.name for i in self.multiworld.precollected_items[self.player])) + items = Counter((i.name for i in self.multiworld.itempool)) + self.assertIn("Progressive Coop", items) self.assertEqual(items["Progressive Coop"], 3) self.assertNotIn("Progressive Coop", start_items) + def test_coop_is_not_logically_available(self): + self.assert_rule_false(self.world.logic.building.has_building("Coop")) -class TestStartInventoryMeadowLands(WorldAssertMixin, SVTestBase): + +class TestStartInventoryMeadowLandsProgressiveBuilding(WorldAssertMixin, SVTestBase): options = { - options.FarmType.internal_name: options.FarmType.option_meadowlands, - options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive, + options.FarmType: options.FarmType.option_meadowlands, + options.BuildingProgression: options.BuildingProgression.option_progressive, } def test_start_inventory_progressive_coops(self): - start_items = dict(map(lambda x: (x.name, self.multiworld.precollected_items[self.player].count(x)), self.multiworld.precollected_items[self.player])) - items = dict(map(lambda x: (x.name, self.multiworld.itempool.count(x)), self.multiworld.itempool)) + start_items = Counter((i.name for i in self.multiworld.precollected_items[self.player])) + items = Counter((i.name for i in self.multiworld.itempool)) + self.assertIn("Progressive Coop", items) self.assertEqual(items["Progressive Coop"], 2) self.assertIn("Progressive Coop", start_items) self.assertEqual(start_items["Progressive Coop"], 1) + + def test_coop_is_logically_available(self): + self.assert_rule_true(self.world.logic.building.has_building("Coop")) + + +class TestStartInventoryMeadowLandsVanillaBuildings(WorldAssertMixin, SVTestBase): + options = { + options.FarmType: options.FarmType.option_meadowlands, + options.BuildingProgression: options.BuildingProgression.option_vanilla, + } + + def test_start_inventory_has_no_coop(self): + start_items = Counter((i.name for i in self.multiworld.precollected_items[self.player])) + self.assertNotIn("Progressive Coop", start_items) + + def test_coop_is_logically_available(self): + self.assert_rule_true(self.world.logic.building.has_building("Coop")) diff --git a/worlds/stardew_valley/test/TestGeneration.py b/worlds/stardew_valley/test/TestGeneration.py index 38882136ce..35cd2007eb 100644 --- a/worlds/stardew_valley/test/TestGeneration.py +++ b/worlds/stardew_valley/test/TestGeneration.py @@ -3,13 +3,30 @@ from typing import List from BaseClasses import ItemClassification, Item from . import SVTestBase from .. import items, location_table, options -from ..items import Group +from ..items import Group, ItemData from ..locations import LocationTags from ..options import Friendsanity, SpecialOrderLocations, Shipsanity, Chefsanity, SeasonRandomization, Craftsanity, ExcludeGingerIsland, SkillProgression, \ Booksanity, Walnutsanity from ..strings.region_names import Region +def get_all_permanent_progression_items() -> List[ItemData]: + """Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression. + """ + return [ + item + for item in items.all_items + if ItemClassification.progression in item.classification + if item.mod_name is None + if item.name not in {event.name for event in items.events} + if item.name not in {deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]} + if item.name not in {season.name for season in items.items_by_group[Group.SEASON]} + if item.name not in {weapon.name for weapon in items.items_by_group[Group.WEAPON]} + if item.name not in {baby.name for baby in items.items_by_group[Group.BABY]} + if item.name != "The Gateway Gazette" + ] + + class TestBaseItemGeneration(SVTestBase): options = { SeasonRandomization.internal_name: SeasonRandomization.option_progressive, @@ -25,17 +42,8 @@ class TestBaseItemGeneration(SVTestBase): } def test_all_progression_items_are_added_to_the_pool(self): - all_created_items = [item.name for item in self.multiworld.itempool] - # Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression - items_to_ignore = [event.name for event in items.events] - items_to_ignore.extend(item.name for item in items.all_items if item.mod_name is not None) - items_to_ignore.extend(deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]) - items_to_ignore.extend(season.name for season in items.items_by_group[Group.SEASON]) - items_to_ignore.extend(weapon.name for weapon in items.items_by_group[Group.WEAPON]) - items_to_ignore.extend(baby.name for baby in items.items_by_group[Group.BABY]) - items_to_ignore.extend(resource_pack.name for resource_pack in items.items_by_group[Group.RESOURCE_PACK]) - items_to_ignore.append("The Gateway Gazette") - progression_items = [item for item in items.all_items if item.classification & ItemClassification.progression and item.name not in items_to_ignore] + all_created_items = set(self.get_all_created_items()) + progression_items = get_all_permanent_progression_items() for progression_item in progression_items: with self.subTest(f"{progression_item.name}"): self.assertIn(progression_item.name, all_created_items) @@ -45,19 +53,19 @@ class TestBaseItemGeneration(SVTestBase): self.assertEqual(len(non_event_locations), len(self.multiworld.itempool)) def test_does_not_create_deprecated_items(self): - all_created_items = [item.name for item in self.multiworld.itempool] + all_created_items = set(self.get_all_created_items()) for deprecated_item in items.items_by_group[items.Group.DEPRECATED]: with self.subTest(f"{deprecated_item.name}"): self.assertNotIn(deprecated_item.name, all_created_items) def test_does_not_create_more_than_one_maximum_one_items(self): - all_created_items = [item.name for item in self.multiworld.itempool] + all_created_items = self.get_all_created_items() for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]: with self.subTest(f"{maximum_one_item.name}"): self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1) - def test_does_not_create_exactly_two_items(self): - all_created_items = [item.name for item in self.multiworld.itempool] + def test_does_not_create_or_create_two_of_exactly_two_items(self): + all_created_items = self.get_all_created_items() for exactly_two_item in items.items_by_group[items.Group.EXACTLY_TWO]: with self.subTest(f"{exactly_two_item.name}"): count = all_created_items.count(exactly_two_item.name) @@ -77,17 +85,10 @@ class TestNoGingerIslandItemGeneration(SVTestBase): } def test_all_progression_items_except_island_are_added_to_the_pool(self): - all_created_items = [item.name for item in self.multiworld.itempool] - # Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression - items_to_ignore = [event.name for event in items.events] - items_to_ignore.extend(item.name for item in items.all_items if item.mod_name is not None) - items_to_ignore.extend(deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]) - items_to_ignore.extend(season.name for season in items.items_by_group[Group.SEASON]) - items_to_ignore.extend(season.name for season in items.items_by_group[Group.WEAPON]) - items_to_ignore.extend(baby.name for baby in items.items_by_group[Group.BABY]) - items_to_ignore.append("The Gateway Gazette") - progression_items = [item for item in items.all_items if item.classification & ItemClassification.progression and item.name not in items_to_ignore] + all_created_items = set(self.get_all_created_items()) + progression_items = get_all_permanent_progression_items() for progression_item in progression_items: + with self.subTest(f"{progression_item.name}"): if Group.GINGER_ISLAND in progression_item.groups: self.assertNotIn(progression_item.name, all_created_items) @@ -100,19 +101,19 @@ class TestNoGingerIslandItemGeneration(SVTestBase): self.assertEqual(len(non_event_locations), len(self.multiworld.itempool)) def test_does_not_create_deprecated_items(self): - all_created_items = [item.name for item in self.multiworld.itempool] + all_created_items = self.get_all_created_items() for deprecated_item in items.items_by_group[items.Group.DEPRECATED]: with self.subTest(f"Deprecated item: {deprecated_item.name}"): self.assertNotIn(deprecated_item.name, all_created_items) def test_does_not_create_more_than_one_maximum_one_items(self): - all_created_items = [item.name for item in self.multiworld.itempool] + all_created_items = self.get_all_created_items() for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]: with self.subTest(f"{maximum_one_item.name}"): self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1) def test_does_not_create_exactly_two_items(self): - all_created_items = [item.name for item in self.multiworld.itempool] + all_created_items = self.get_all_created_items() for exactly_two_item in items.items_by_group[items.Group.EXACTLY_TWO]: with self.subTest(f"{exactly_two_item.name}"): count = all_created_items.count(exactly_two_item.name) diff --git a/worlds/stardew_valley/test/TestLogic.py b/worlds/stardew_valley/test/TestLogic.py index 85279ad238..7a6b81ea74 100644 --- a/worlds/stardew_valley/test/TestLogic.py +++ b/worlds/stardew_valley/test/TestLogic.py @@ -49,9 +49,9 @@ class LogicTestBase(RuleAssertMixin, TestCase): self.assert_rule_can_be_resolved(rule, self.multiworld.state) def test_given_building_rule_then_can_be_resolved(self): - for building in self.logic.registry.building_rules.keys(): + for building in self.world.content.farm_buildings: with self.subTest(msg=building): - rule = self.logic.registry.building_rules[building] + rule = self.logic.building.can_build(building) self.assert_rule_can_be_resolved(rule, self.multiworld.state) def test_given_quest_rule_then_can_be_resolved(self): diff --git a/worlds/stardew_valley/test/TestOptionFlags.py b/worlds/stardew_valley/test/TestOptionFlags.py index 88f2257cab..2833649e35 100644 --- a/worlds/stardew_valley/test/TestOptionFlags.py +++ b/worlds/stardew_valley/test/TestOptionFlags.py @@ -8,9 +8,8 @@ class TestBitFlagsVanilla(SVTestBase): BuildingProgression.internal_name: BuildingProgression.option_vanilla} def test_options_are_not_detected_as_progressive(self): - world_options = self.world.options tool_progressive = self.world.content.features.tool_progression.is_progressive - building_progressive = world_options.building_progression & BuildingProgression.option_progressive + building_progressive = self.world.content.features.building_progression.is_progressive self.assertFalse(tool_progressive) self.assertFalse(building_progressive) @@ -25,9 +24,8 @@ class TestBitFlagsVanillaCheap(SVTestBase): BuildingProgression.internal_name: BuildingProgression.option_vanilla_cheap} def test_options_are_not_detected_as_progressive(self): - world_options = self.world.options tool_progressive = self.world.content.features.tool_progression.is_progressive - building_progressive = world_options.building_progression & BuildingProgression.option_progressive + building_progressive = self.world.content.features.building_progression.is_progressive self.assertFalse(tool_progressive) self.assertFalse(building_progressive) @@ -42,9 +40,8 @@ class TestBitFlagsVanillaVeryCheap(SVTestBase): BuildingProgression.internal_name: BuildingProgression.option_vanilla_very_cheap} def test_options_are_not_detected_as_progressive(self): - world_options = self.world.options tool_progressive = self.world.content.features.tool_progression.is_progressive - building_progressive = world_options.building_progression & BuildingProgression.option_progressive + building_progressive = self.world.content.features.building_progression.is_progressive self.assertFalse(tool_progressive) self.assertFalse(building_progressive) @@ -59,9 +56,8 @@ class TestBitFlagsProgressive(SVTestBase): BuildingProgression.internal_name: BuildingProgression.option_progressive} def test_options_are_detected_as_progressive(self): - world_options = self.world.options tool_progressive = self.world.content.features.tool_progression.is_progressive - building_progressive = world_options.building_progression & BuildingProgression.option_progressive + building_progressive = self.world.content.features.building_progression.is_progressive self.assertTrue(tool_progressive) self.assertTrue(building_progressive) @@ -76,9 +72,8 @@ class TestBitFlagsProgressiveCheap(SVTestBase): BuildingProgression.internal_name: BuildingProgression.option_progressive_cheap} def test_options_are_detected_as_progressive(self): - world_options = self.world.options tool_progressive = self.world.content.features.tool_progression.is_progressive - building_progressive = world_options.building_progression & BuildingProgression.option_progressive + building_progressive = self.world.content.features.building_progression.is_progressive self.assertTrue(tool_progressive) self.assertTrue(building_progressive) @@ -93,9 +88,8 @@ class TestBitFlagsProgressiveVeryCheap(SVTestBase): BuildingProgression.internal_name: BuildingProgression.option_progressive_very_cheap} def test_options_are_detected_as_progressive(self): - world_options = self.world.options tool_progressive = self.world.content.features.tool_progression.is_progressive - building_progressive = world_options.building_progression & BuildingProgression.option_progressive + building_progressive = self.world.content.features.building_progression.is_progressive self.assertTrue(tool_progressive) self.assertTrue(building_progressive) diff --git a/worlds/stardew_valley/test/TestWalnutsanity.py b/worlds/stardew_valley/test/TestWalnutsanity.py index e3f06bf133..5cc2f79e91 100644 --- a/worlds/stardew_valley/test/TestWalnutsanity.py +++ b/worlds/stardew_valley/test/TestWalnutsanity.py @@ -70,7 +70,6 @@ class TestWalnutsanityPuzzles(SVTestBase): def test_field_office_locations_require_professor_snail(self): location_names = ["Complete Large Animal Collection", "Complete Snake Collection", "Complete Mummified Frog Collection", "Complete Mummified Bat Collection", "Purple Flowers Island Survey", "Purple Starfish Island Survey", ] - locations = [location for location in self.multiworld.get_locations() if location.name in location_names] self.collect("Island Obelisk") self.collect("Island North Turtle") self.collect("Island West Turtle") @@ -84,11 +83,11 @@ class TestWalnutsanityPuzzles(SVTestBase): self.collect("Progressive Sword", 5) self.collect("Combat Level", 10) self.collect("Mining Level", 10) - for location in locations: - self.assert_cannot_reach_location(location, self.multiworld.state) + for location in location_names: + self.assert_cannot_reach_location(location) self.collect("Open Professor Snail Cave") - for location in locations: - self.assert_can_reach_location(location, self.multiworld.state) + for location in location_names: + self.assert_can_reach_location(location) class TestWalnutsanityBushes(SVTestBase): diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py index f06e7d0768..800b210576 100644 --- a/worlds/stardew_valley/test/__init__.py +++ b/worlds/stardew_valley/test/__init__.py @@ -1,3 +1,4 @@ +import itertools import logging import os import threading @@ -11,7 +12,8 @@ from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_mul from worlds.AutoWorld import call_all from .assertion import RuleAssertMixin from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default -from .. import StardewValleyWorld, StardewItem +from .. import StardewValleyWorld, StardewItem, StardewRule +from ..logic.time_logic import MONTH_COEFFICIENT from ..options import StardewValleyOption logger = logging.getLogger(__name__) @@ -96,6 +98,12 @@ class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase): return False return super().run_default_tests + def collect_months(self, months: int) -> None: + real_total_prog_items = self.world.total_progression_items + percent = months * MONTH_COEFFICIENT + self.collect("Stardrop", real_total_prog_items * 100 // percent) + self.world.total_progression_items = real_total_prog_items + def collect_lots_of_money(self, percent: float = 0.25): self.collect("Shipping Bin") real_total_prog_items = self.world.total_progression_items @@ -145,12 +153,35 @@ class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase): def create_item(self, item: str) -> StardewItem: return self.world.create_item(item) + def get_all_created_items(self) -> list[str]: + return [item.name for item in itertools.chain(self.multiworld.get_items(), self.multiworld.precollected_items[self.player])] + def remove_one_by_name(self, item: str) -> None: self.remove(self.create_item(item)) - def reset_collection_state(self): + def reset_collection_state(self) -> None: self.multiworld.state = self.original_state.copy() + def assert_rule_true(self, rule: StardewRule, state: CollectionState | None = None) -> None: + if state is None: + state = self.multiworld.state + super().assert_rule_true(rule, state) + + def assert_rule_false(self, rule: StardewRule, state: CollectionState | None = None) -> None: + if state is None: + state = self.multiworld.state + super().assert_rule_false(rule, state) + + def assert_can_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None: + if state is None: + state = self.multiworld.state + super().assert_can_reach_location(location, state) + + def assert_cannot_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None: + if state is None: + state = self.multiworld.state + super().assert_cannot_reach_location(location, state) + pre_generated_worlds = {} diff --git a/worlds/stardew_valley/test/content/__init__.py b/worlds/stardew_valley/test/content/__init__.py index 0832c2e3c3..626277ca72 100644 --- a/worlds/stardew_valley/test/content/__init__.py +++ b/worlds/stardew_valley/test/content/__init__.py @@ -2,9 +2,11 @@ import unittest from typing import ClassVar, Tuple from ...content import content_packs, ContentPack, StardewContent, unpack_content, StardewFeatures, feature +from ...strings.building_names import Building default_features = StardewFeatures( feature.booksanity.BooksanityDisabled(), + feature.building_progression.BuildingProgressionVanilla(starting_buildings={Building.farm_house}), feature.cropsanity.CropsanityDisabled(), feature.fishsanity.FishsanityNone(), feature.friendsanity.FriendsanityNone(), diff --git a/worlds/stardew_valley/test/long/TestOptionsLong.py b/worlds/stardew_valley/test/long/TestOptionsLong.py index 0c8cfcb1e1..81bb4d1f30 100644 --- a/worlds/stardew_valley/test/long/TestOptionsLong.py +++ b/worlds/stardew_valley/test/long/TestOptionsLong.py @@ -6,7 +6,6 @@ from .option_names import all_option_choices from .. import SVTestCase, solo_multiworld from ..assertion.world_assert import WorldAssertMixin from ... import options -from ...mods.mod_data import ModNames class TestGenerateDynamicOptions(WorldAssertMixin, SVTestCase): @@ -34,13 +33,11 @@ class TestDynamicOptionDebug(WorldAssertMixin, SVTestCase): def test_option_pair_debug(self): option_dict = { - options.Goal.internal_name: options.Goal.option_master_angler, - options.QuestLocations.internal_name: -1, - options.Fishsanity.internal_name: options.Fishsanity.option_all, - options.Mods.internal_name: frozenset({ModNames.sve}), + options.Goal.internal_name: options.Goal.option_cryptic_note, + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_buildings, } for i in range(1): - seed = get_seed() + seed = get_seed(76312028554502615508) with self.subTest(f"Seed: {seed}"): print(f"Seed: {seed}") with solo_multiworld(option_dict, seed=seed) as (multiworld, _): diff --git a/worlds/stardew_valley/test/mods/TestMods.py b/worlds/stardew_valley/test/mods/TestMods.py index b4d10f2e99..932c76c680 100644 --- a/worlds/stardew_valley/test/mods/TestMods.py +++ b/worlds/stardew_valley/test/mods/TestMods.py @@ -1,11 +1,12 @@ import random -from BaseClasses import get_seed, ItemClassification +from BaseClasses import get_seed from .. import SVTestBase, SVTestCase +from ..TestGeneration import get_all_permanent_progression_items from ..assertion import ModAssertMixin, WorldAssertMixin from ..options.presets import allsanity_mods_6_x_x from ..options.utils import fill_dataclass_with_default -from ... import options, items, Group, create_content +from ... import options, Group, create_content from ...mods.mod_data import ModNames from ...options import SkillProgression, Walnutsanity from ...options.options import all_mods @@ -109,17 +110,8 @@ class TestBaseItemGeneration(SVTestBase): } def test_all_progression_items_are_added_to_the_pool(self): - all_created_items = [item.name for item in self.multiworld.itempool] - # Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression - items_to_ignore = [event.name for event in items.events] - items_to_ignore.extend(deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]) - items_to_ignore.extend(season.name for season in items.items_by_group[Group.SEASON]) - items_to_ignore.extend(weapon.name for weapon in items.items_by_group[Group.WEAPON]) - items_to_ignore.extend(baby.name for baby in items.items_by_group[Group.BABY]) - items_to_ignore.extend(resource_pack.name for resource_pack in items.items_by_group[Group.RESOURCE_PACK]) - items_to_ignore.append("The Gateway Gazette") - progression_items = [item for item in items.all_items if item.classification & ItemClassification.progression - and item.name not in items_to_ignore] + all_created_items = self.get_all_created_items() + progression_items = get_all_permanent_progression_items() for progression_item in progression_items: with self.subTest(f"{progression_item.name}"): self.assertIn(progression_item.name, all_created_items) @@ -139,17 +131,8 @@ class TestNoGingerIslandModItemGeneration(SVTestBase): } def test_all_progression_items_except_island_are_added_to_the_pool(self): - all_created_items = [item.name for item in self.multiworld.itempool] - # Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression - items_to_ignore = [event.name for event in items.events] - items_to_ignore.extend(deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]) - items_to_ignore.extend(season.name for season in items.items_by_group[Group.SEASON]) - items_to_ignore.extend(weapon.name for weapon in items.items_by_group[Group.WEAPON]) - items_to_ignore.extend(baby.name for baby in items.items_by_group[Group.BABY]) - items_to_ignore.extend(resource_pack.name for resource_pack in items.items_by_group[Group.RESOURCE_PACK]) - items_to_ignore.append("The Gateway Gazette") - progression_items = [item for item in items.all_items if item.classification & ItemClassification.progression - and item.name not in items_to_ignore] + all_created_items = self.get_all_created_items() + progression_items = get_all_permanent_progression_items() for progression_item in progression_items: with self.subTest(f"{progression_item.name}"): if Group.GINGER_ISLAND in progression_item.groups: diff --git a/worlds/stardew_valley/test/options/presets.py b/worlds/stardew_valley/test/options/presets.py index aecdeadd9f..57f8b0beb9 100644 --- a/worlds/stardew_valley/test/options/presets.py +++ b/worlds/stardew_valley/test/options/presets.py @@ -16,6 +16,7 @@ def default_6_x_x(): options.ElevatorProgression.internal_name: options.ElevatorProgression.default, options.EntranceRandomization.internal_name: options.EntranceRandomization.default, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.default, + options.FarmType.internal_name: options.FarmType.default, options.FestivalLocations.internal_name: options.FestivalLocations.default, options.Fishsanity.internal_name: options.Fishsanity.default, options.Friendsanity.internal_name: options.Friendsanity.default, @@ -52,6 +53,7 @@ def allsanity_no_mods_6_x_x(): options.ElevatorProgression.internal_name: options.ElevatorProgression.option_progressive, options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, + options.FarmType.internal_name: options.FarmType.option_standard, options.FestivalLocations.internal_name: options.FestivalLocations.option_hard, options.Fishsanity.internal_name: options.Fishsanity.option_all, options.Friendsanity.internal_name: options.Friendsanity.option_all_with_marriage, @@ -100,6 +102,7 @@ def get_minsanity_options(): options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.FarmType.internal_name: options.FarmType.option_meadowlands, options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, options.Fishsanity.internal_name: options.Fishsanity.option_none, options.Friendsanity.internal_name: options.Friendsanity.option_none, @@ -136,6 +139,7 @@ def minimal_locations_maximal_items(): options.ElevatorProgression.internal_name: options.ElevatorProgression.option_vanilla, options.EntranceRandomization.internal_name: options.EntranceRandomization.option_disabled, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true, + options.FarmType.internal_name: options.FarmType.option_meadowlands, options.FestivalLocations.internal_name: options.FestivalLocations.option_disabled, options.Fishsanity.internal_name: options.Fishsanity.option_none, options.Friendsanity.internal_name: options.Friendsanity.option_none, diff --git a/worlds/stardew_valley/test/rules/TestArcades.py b/worlds/stardew_valley/test/rules/TestArcades.py index 69e5b22cc0..5fdf7df13d 100644 --- a/worlds/stardew_valley/test/rules/TestArcades.py +++ b/worlds/stardew_valley/test/rules/TestArcades.py @@ -11,7 +11,7 @@ class TestArcadeMachinesLogic(SVTestBase): self.assertFalse(self.world.logic.region.can_reach("JotPK World 1")(self.multiworld.state)) self.assertFalse(self.world.logic.region.can_reach("JotPK World 2")(self.multiworld.state)) self.assertFalse(self.world.logic.region.can_reach("JotPK World 3")(self.multiworld.state)) - self.assertFalse(self.world.logic.region.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state)) + self.assert_cannot_reach_location("Journey of the Prairie King Victory") boots = self.create_item("JotPK: Progressive Boots") gun = self.create_item("JotPK: Progressive Gun") @@ -24,7 +24,7 @@ class TestArcadeMachinesLogic(SVTestBase): self.assertTrue(self.world.logic.region.can_reach("JotPK World 1")(self.multiworld.state)) self.assertFalse(self.world.logic.region.can_reach("JotPK World 2")(self.multiworld.state)) self.assertFalse(self.world.logic.region.can_reach("JotPK World 3")(self.multiworld.state)) - self.assertFalse(self.world.logic.region.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state)) + self.assert_cannot_reach_location("Journey of the Prairie King Victory") self.remove(boots) self.remove(gun) @@ -33,7 +33,7 @@ class TestArcadeMachinesLogic(SVTestBase): self.assertTrue(self.world.logic.region.can_reach("JotPK World 1")(self.multiworld.state)) self.assertFalse(self.world.logic.region.can_reach("JotPK World 2")(self.multiworld.state)) self.assertFalse(self.world.logic.region.can_reach("JotPK World 3")(self.multiworld.state)) - self.assertFalse(self.world.logic.region.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state)) + self.assert_cannot_reach_location("Journey of the Prairie King Victory") self.remove(boots) self.remove(boots) @@ -44,7 +44,7 @@ class TestArcadeMachinesLogic(SVTestBase): self.assertTrue(self.world.logic.region.can_reach("JotPK World 1")(self.multiworld.state)) self.assertTrue(self.world.logic.region.can_reach("JotPK World 2")(self.multiworld.state)) self.assertFalse(self.world.logic.region.can_reach("JotPK World 3")(self.multiworld.state)) - self.assertFalse(self.world.logic.region.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state)) + self.assert_cannot_reach_location("Journey of the Prairie King Victory") self.remove(boots) self.remove(gun) self.remove(ammo) @@ -60,7 +60,7 @@ class TestArcadeMachinesLogic(SVTestBase): self.assertTrue(self.world.logic.region.can_reach("JotPK World 1")(self.multiworld.state)) self.assertTrue(self.world.logic.region.can_reach("JotPK World 2")(self.multiworld.state)) self.assertTrue(self.world.logic.region.can_reach("JotPK World 3")(self.multiworld.state)) - self.assertFalse(self.world.logic.region.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state)) + self.assert_cannot_reach_location("Journey of the Prairie King Victory") self.remove(boots) self.remove(gun) self.remove(gun) @@ -83,7 +83,7 @@ class TestArcadeMachinesLogic(SVTestBase): self.assertTrue(self.world.logic.region.can_reach("JotPK World 1")(self.multiworld.state)) self.assertTrue(self.world.logic.region.can_reach("JotPK World 2")(self.multiworld.state)) self.assertTrue(self.world.logic.region.can_reach("JotPK World 3")(self.multiworld.state)) - self.assertTrue(self.world.logic.region.can_reach_location("Journey of the Prairie King Victory")(self.multiworld.state)) + self.assert_can_reach_location("Journey of the Prairie King Victory") self.remove(boots) self.remove(boots) self.remove(gun) diff --git a/worlds/stardew_valley/test/rules/TestBooks.py b/worlds/stardew_valley/test/rules/TestBooks.py index af0055d228..4cd84a77a2 100644 --- a/worlds/stardew_valley/test/rules/TestBooks.py +++ b/worlds/stardew_valley/test/rules/TestBooks.py @@ -7,18 +7,15 @@ class TestBooksLogic(SVTestBase): options.Booksanity.internal_name: options.Booksanity.option_all, } - def test_need_weapon_for_mapping_cave_systems(self): - self.collect_lots_of_money(0.5) - - location = self.multiworld.get_location("Read Mapping Cave Systems", self.player) - - self.assert_cannot_reach_location(location, self.multiworld.state) + def test_can_get_mapping_cave_systems_with_weapon_and_time(self): + self.collect_months(12) + self.assert_cannot_reach_location("Read Mapping Cave Systems") self.collect("Progressive Mine Elevator") self.collect("Progressive Mine Elevator") self.collect("Progressive Mine Elevator") self.collect("Progressive Mine Elevator") - self.assert_cannot_reach_location(location, self.multiworld.state) + self.assert_cannot_reach_location("Read Mapping Cave Systems") self.collect("Progressive Weapon") - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location("Read Mapping Cave Systems") diff --git a/worlds/stardew_valley/test/rules/TestBuildings.py b/worlds/stardew_valley/test/rules/TestBuildings.py index d1f60b20e0..8eeb9d295a 100644 --- a/worlds/stardew_valley/test/rules/TestBuildings.py +++ b/worlds/stardew_valley/test/rules/TestBuildings.py @@ -9,45 +9,37 @@ class TestBuildingLogic(SVTestBase): } def test_coop_blueprint(self): - self.assertFalse(self.world.logic.region.can_reach_location("Coop Blueprint")(self.multiworld.state)) + self.assert_cannot_reach_location("Coop Blueprint") self.collect_lots_of_money() - self.assertTrue(self.world.logic.region.can_reach_location("Coop Blueprint")(self.multiworld.state)) + self.assert_can_reach_location("Coop Blueprint") def test_big_coop_blueprint(self): - big_coop_blueprint_rule = self.world.logic.region.can_reach_location("Big Coop Blueprint") - self.assertFalse(big_coop_blueprint_rule(self.multiworld.state), - f"Rule is {repr(self.multiworld.get_location('Big Coop Blueprint', self.player).access_rule)}") + self.assert_cannot_reach_location("Big Coop Blueprint") self.collect_lots_of_money() - self.assertFalse(big_coop_blueprint_rule(self.multiworld.state), - f"Rule is {repr(self.multiworld.get_location('Big Coop Blueprint', self.player).access_rule)}") + self.assert_cannot_reach_location("Big Coop Blueprint") self.multiworld.state.collect(self.create_item("Progressive Coop")) - self.assertTrue(big_coop_blueprint_rule(self.multiworld.state), - f"Rule is {repr(self.multiworld.get_location('Big Coop Blueprint', self.player).access_rule)}") + self.assert_can_reach_location("Big Coop Blueprint") def test_deluxe_coop_blueprint(self): - self.assertFalse(self.world.logic.region.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state)) + self.assert_cannot_reach_location("Deluxe Coop Blueprint") self.collect_lots_of_money() - self.assertFalse(self.world.logic.region.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state)) + self.assert_cannot_reach_location("Deluxe Coop Blueprint") self.multiworld.state.collect(self.create_item("Progressive Coop")) - self.assertFalse(self.world.logic.region.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state)) + self.assert_cannot_reach_location("Deluxe Coop Blueprint") self.multiworld.state.collect(self.create_item("Progressive Coop")) - self.assertTrue(self.world.logic.region.can_reach_location("Deluxe Coop Blueprint")(self.multiworld.state)) + self.assert_can_reach_location("Deluxe Coop Blueprint") def test_big_shed_blueprint(self): - big_shed_rule = self.world.logic.region.can_reach_location("Big Shed Blueprint") - self.assertFalse(big_shed_rule(self.multiworld.state), - f"Rule is {repr(self.multiworld.get_location('Big Shed Blueprint', self.player).access_rule)}") + self.assert_cannot_reach_location("Big Shed Blueprint") self.collect_lots_of_money() - self.assertFalse(big_shed_rule(self.multiworld.state), - f"Rule is {repr(self.multiworld.get_location('Big Shed Blueprint', self.player).access_rule)}") + self.assert_cannot_reach_location("Big Shed Blueprint") self.multiworld.state.collect(self.create_item("Progressive Shed")) - self.assertTrue(big_shed_rule(self.multiworld.state), - f"Rule is {repr(self.multiworld.get_location('Big Shed Blueprint', self.player).access_rule)}") + self.assert_can_reach_location("Big Shed Blueprint") diff --git a/worlds/stardew_valley/test/rules/TestBundles.py b/worlds/stardew_valley/test/rules/TestBundles.py index 0bc7f9bfdf..918cb8aba6 100644 --- a/worlds/stardew_valley/test/rules/TestBundles.py +++ b/worlds/stardew_valley/test/rules/TestBundles.py @@ -11,10 +11,10 @@ class TestBundlesLogic(SVTestBase): } def test_vault_2500g_bundle(self): - self.assertFalse(self.world.logic.region.can_reach_location("2,500g Bundle")(self.multiworld.state)) + self.assert_cannot_reach_location("2,500g Bundle") self.collect_lots_of_money() - self.assertTrue(self.world.logic.region.can_reach_location("2,500g Bundle")(self.multiworld.state)) + self.assert_can_reach_location("2,500g Bundle") class TestRemixedBundlesLogic(SVTestBase): @@ -25,10 +25,10 @@ class TestRemixedBundlesLogic(SVTestBase): } def test_sticky_bundle_has_grind_rules(self): - self.assertFalse(self.world.logic.region.can_reach_location("Sticky Bundle")(self.multiworld.state)) + self.assert_cannot_reach_location("Sticky Bundle") self.collect_all_the_money() - self.assertTrue(self.world.logic.region.can_reach_location("Sticky Bundle")(self.multiworld.state)) + self.assert_can_reach_location("Sticky Bundle") class TestRaccoonBundlesLogic(SVTestBase): @@ -40,11 +40,6 @@ class TestRaccoonBundlesLogic(SVTestBase): seed = 2 # Magic seed that does what I want. Might need to get changed if we change the randomness behavior of raccoon bundles def test_raccoon_bundles_rely_on_previous_ones(self): - # The first raccoon bundle is a fishing one - raccoon_rule_1 = self.world.logic.region.can_reach_location("Raccoon Request 1") - - # The 3th raccoon bundle is a foraging one - raccoon_rule_3 = self.world.logic.region.can_reach_location("Raccoon Request 3") self.collect("Progressive Raccoon", 6) self.collect("Progressive Mine Elevator", 24) self.collect("Mining Level", 12) @@ -58,10 +53,12 @@ class TestRaccoonBundlesLogic(SVTestBase): self.collect("Fishing Level", 10) self.collect("Furnace Recipe") - self.assertFalse(raccoon_rule_1(self.multiworld.state)) - self.assertFalse(raccoon_rule_3(self.multiworld.state)) + # The first raccoon bundle is a fishing one + self.assert_cannot_reach_location("Raccoon Request 1") + # The third raccoon bundle is a foraging one + self.assert_cannot_reach_location("Raccoon Request 3") self.collect("Fish Smoker Recipe") - self.assertTrue(raccoon_rule_1(self.multiworld.state)) - self.assertTrue(raccoon_rule_3(self.multiworld.state)) + self.assert_can_reach_location("Raccoon Request 1") + self.assert_can_reach_location("Raccoon Request 3") diff --git a/worlds/stardew_valley/test/rules/TestCookingRecipes.py b/worlds/stardew_valley/test/rules/TestCookingRecipes.py index d5f9da73c9..b3aafdb690 100644 --- a/worlds/stardew_valley/test/rules/TestCookingRecipes.py +++ b/worlds/stardew_valley/test/rules/TestCookingRecipes.py @@ -14,18 +14,17 @@ class TestRecipeLearnLogic(SVTestBase): def test_can_learn_qos_recipe(self): location = "Cook Radish Salad" - rule = self.world.logic.region.can_reach_location(location) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) self.multiworld.state.collect(self.create_item("Progressive House")) self.multiworld.state.collect(self.create_item("Radish Seeds")) self.multiworld.state.collect(self.create_item("Spring")) self.multiworld.state.collect(self.create_item("Summer")) self.collect_lots_of_money() - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) self.multiworld.state.collect(self.create_item("The Queen of Sauce")) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_can_reach_location(location) class TestRecipeReceiveLogic(SVTestBase): @@ -39,34 +38,32 @@ class TestRecipeReceiveLogic(SVTestBase): def test_can_learn_qos_recipe(self): location = "Cook Radish Salad" - rule = self.world.logic.region.can_reach_location(location) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) self.multiworld.state.collect(self.create_item("Progressive House")) self.multiworld.state.collect(self.create_item("Radish Seeds")) self.multiworld.state.collect(self.create_item("Summer")) self.collect_lots_of_money() - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) spring = self.create_item("Spring") qos = self.create_item("The Queen of Sauce") self.multiworld.state.collect(spring) self.multiworld.state.collect(qos) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) self.multiworld.state.remove(spring) self.multiworld.state.remove(qos) self.multiworld.state.collect(self.create_item("Radish Salad Recipe")) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_can_reach_location(location) def test_get_chefsanity_check_recipe(self): location = "Radish Salad Recipe" - rule = self.world.logic.region.can_reach_location(location) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) self.multiworld.state.collect(self.create_item("Spring")) self.collect_lots_of_money() - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) seeds = self.create_item("Radish Seeds") summer = self.create_item("Summer") @@ -74,10 +71,10 @@ class TestRecipeReceiveLogic(SVTestBase): self.multiworld.state.collect(seeds) self.multiworld.state.collect(summer) self.multiworld.state.collect(house) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location(location) self.multiworld.state.remove(seeds) self.multiworld.state.remove(summer) self.multiworld.state.remove(house) self.multiworld.state.collect(self.create_item("The Queen of Sauce")) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_can_reach_location(location) diff --git a/worlds/stardew_valley/test/rules/TestCraftingRecipes.py b/worlds/stardew_valley/test/rules/TestCraftingRecipes.py index 46a1b73d0b..94d6bc145a 100644 --- a/worlds/stardew_valley/test/rules/TestCraftingRecipes.py +++ b/worlds/stardew_valley/test/rules/TestCraftingRecipes.py @@ -13,8 +13,6 @@ class TestCraftsanityLogic(SVTestBase): } def test_can_craft_recipe(self): - location = "Craft Marble Brazier" - rule = self.world.logic.region.can_reach_location(location) self.collect([self.create_item("Progressive Pickaxe")] * 4) self.collect([self.create_item("Progressive Fishing Rod")] * 4) self.collect([self.create_item("Progressive Sword")] * 4) @@ -23,18 +21,16 @@ class TestCraftsanityLogic(SVTestBase): self.collect([self.create_item("Combat Level")] * 10) self.collect([self.create_item("Fishing Level")] * 10) self.collect_all_the_money() - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Craft Marble Brazier") self.multiworld.state.collect(self.create_item("Marble Brazier Recipe")) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_can_reach_location("Craft Marble Brazier") def test_can_learn_crafting_recipe(self): - location = "Marble Brazier Recipe" - rule = self.world.logic.region.can_reach_location(location) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Marble Brazier Recipe") self.collect_lots_of_money() - self.assert_rule_true(rule, self.multiworld.state) + self.assert_can_reach_location("Marble Brazier Recipe") def test_can_craft_festival_recipe(self): recipe = all_crafting_recipes_by_name["Jack-O-Lantern"] @@ -42,13 +38,13 @@ class TestCraftsanityLogic(SVTestBase): self.multiworld.state.collect(self.create_item("Torch Recipe")) self.collect_lots_of_money() rule = self.world.logic.crafting.can_craft(recipe) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_rule_false(rule) self.multiworld.state.collect(self.create_item("Fall")) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_rule_false(rule) self.multiworld.state.collect(self.create_item("Jack-O-Lantern Recipe")) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_rule_true(rule) def test_require_furnace_recipe_for_smelting_checks(self): locations = ["Craft Furnace", "Smelting", "Copper Pickaxe Upgrade", "Gold Trash Can Upgrade"] @@ -83,13 +79,13 @@ class TestCraftsanityWithFestivalsLogic(SVTestBase): self.multiworld.state.collect(self.create_item("Fall")) self.collect_lots_of_money() rule = self.world.logic.crafting.can_craft(recipe) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_rule_false(rule) self.multiworld.state.collect(self.create_item("Jack-O-Lantern Recipe")) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_rule_false(rule) self.multiworld.state.collect(self.create_item("Torch Recipe")) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_rule_true(rule) class TestNoCraftsanityLogic(SVTestBase): @@ -105,7 +101,7 @@ class TestNoCraftsanityLogic(SVTestBase): def test_can_craft_recipe(self): recipe = all_crafting_recipes_by_name["Wood Floor"] rule = self.world.logic.crafting.can_craft(recipe) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_rule_true(rule) def test_can_craft_festival_recipe(self): recipe = all_crafting_recipes_by_name["Jack-O-Lantern"] @@ -116,7 +112,7 @@ class TestNoCraftsanityLogic(SVTestBase): self.assertFalse(result) self.collect([self.create_item("Progressive Season")] * 2) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_rule_true(rule) def test_requires_mining_levels_for_smelting_checks(self): locations = ["Smelting", "Copper Pickaxe Upgrade", "Gold Trash Can Upgrade"] @@ -151,7 +147,7 @@ class TestNoCraftsanityWithFestivalsLogic(SVTestBase): self.multiworld.state.collect(self.create_item("Fall")) self.collect_lots_of_money() rule = self.world.logic.crafting.can_craft(recipe) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_rule_false(rule) self.multiworld.state.collect(self.create_item("Jack-O-Lantern Recipe")) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_rule_true(rule) diff --git a/worlds/stardew_valley/test/rules/TestDonations.py b/worlds/stardew_valley/test/rules/TestDonations.py index 3927bd09a4..d50f87d3e9 100644 --- a/worlds/stardew_valley/test/rules/TestDonations.py +++ b/worlds/stardew_valley/test/rules/TestDonations.py @@ -16,12 +16,12 @@ class TestDonationLogicAll(SVTestBase): self.collect_all_except(railroad_item) for donation in locations_by_tag[LocationTags.MUSEUM_DONATIONS]: - self.assertFalse(self.world.logic.region.can_reach_location(donation.name)(self.multiworld.state)) + self.assert_cannot_reach_location(donation.name) self.multiworld.state.collect(self.create_item(railroad_item)) for donation in locations_by_tag[LocationTags.MUSEUM_DONATIONS]: - self.assertTrue(self.world.logic.region.can_reach_location(donation.name)(self.multiworld.state)) + self.assert_can_reach_location(donation.name) class TestDonationLogicRandomized(SVTestBase): @@ -37,12 +37,12 @@ class TestDonationLogicRandomized(SVTestBase): LocationTags.MUSEUM_DONATIONS in location_table[location.name].tags] for donation in donation_locations: - self.assertFalse(self.world.logic.region.can_reach_location(donation.name)(self.multiworld.state)) + self.assert_cannot_reach_location(donation.name) self.multiworld.state.collect(self.create_item(railroad_item)) for donation in donation_locations: - self.assertTrue(self.world.logic.region.can_reach_location(donation.name)(self.multiworld.state)) + self.assert_can_reach_location(donation.name) class TestDonationLogicMilestones(SVTestBase): @@ -56,12 +56,12 @@ class TestDonationLogicMilestones(SVTestBase): self.collect_all_except(railroad_item) for donation in locations_by_tag[LocationTags.MUSEUM_MILESTONES]: - self.assertFalse(self.world.logic.region.can_reach_location(donation.name)(self.multiworld.state)) + self.assert_cannot_reach_location(donation.name) self.multiworld.state.collect(self.create_item(railroad_item)) for donation in locations_by_tag[LocationTags.MUSEUM_MILESTONES]: - self.assertTrue(self.world.logic.region.can_reach_location(donation.name)(self.multiworld.state)) + self.assert_can_reach_location(donation.name) def swap_museum_and_bathhouse(multiworld, player): diff --git a/worlds/stardew_valley/test/rules/TestFishing.py b/worlds/stardew_valley/test/rules/TestFishing.py index 74a33f3668..6a6a4bb315 100644 --- a/worlds/stardew_valley/test/rules/TestFishing.py +++ b/worlds/stardew_valley/test/rules/TestFishing.py @@ -42,19 +42,18 @@ class TestNeedRegionToCatchFish(SVTestBase): with self.subTest(f"Region rules for {fish}"): self.collect_all_the_money() item_names = fish_and_items[fish] - location = self.multiworld.get_location(f"Fishsanity: {fish}", self.player) - self.assert_cannot_reach_location(location, self.multiworld.state) + self.assert_cannot_reach_location(f"Fishsanity: {fish}") items = [] for item_name in item_names: items.append(self.collect(item_name)) with self.subTest(f"{fish} can be reached with {item_names}"): - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location(f"Fishsanity: {fish}") for item_required in items: self.multiworld.state = self.original_state.copy() with self.subTest(f"{fish} requires {item_required.name}"): for item_to_collect in items: if item_to_collect.name != item_required.name: self.collect(item_to_collect) - self.assert_cannot_reach_location(location, self.multiworld.state) + self.assert_cannot_reach_location(f"Fishsanity: {fish}") self.multiworld.state = self.original_state.copy() diff --git a/worlds/stardew_valley/test/rules/TestFriendship.py b/worlds/stardew_valley/test/rules/TestFriendship.py index 3e9109ed50..9cd3127aa3 100644 --- a/worlds/stardew_valley/test/rules/TestFriendship.py +++ b/worlds/stardew_valley/test/rules/TestFriendship.py @@ -47,12 +47,8 @@ class TestFriendsanityDatingRules(SVTestBase): for i in range(1, max_reachable + 1): if i % step != 0 and i != 14: continue - location = f"{prefix}{npc} {i}{suffix}" - can_reach = self.world.logic.region.can_reach_location(location)(self.multiworld.state) - self.assertTrue(can_reach, f"Should be able to earn relationship up to {i} hearts") + self.assert_can_reach_location(f"{prefix}{npc} {i}{suffix}") for i in range(max_reachable + 1, 14 + 1): if i % step != 0 and i != 14: continue - location = f"{prefix}{npc} {i}{suffix}" - can_reach = self.world.logic.region.can_reach_location(location)(self.multiworld.state) - self.assertFalse(can_reach, f"Should not be able to earn relationship up to {i} hearts") + self.assert_cannot_reach_location(f"{prefix}{npc} {i}{suffix}") diff --git a/worlds/stardew_valley/test/rules/TestShipping.py b/worlds/stardew_valley/test/rules/TestShipping.py index 125b7f31d0..fc61ae8e2a 100644 --- a/worlds/stardew_valley/test/rules/TestShipping.py +++ b/worlds/stardew_valley/test/rules/TestShipping.py @@ -76,10 +76,8 @@ class TestShipsanityEverything(SVTestBase): for location in shipsanity_locations: with self.subTest(location.name): - self.assertFalse(self.world.logic.region.can_reach_location(location.name)(self.multiworld.state)) + self.assert_cannot_reach_location(location.name) self.collect(bin_item) - shipsanity_rule = self.world.logic.region.can_reach_location(location.name) - self.assert_rule_true(shipsanity_rule, self.multiworld.state) - + self.assert_can_reach_location(location.name) self.remove(bin_item) diff --git a/worlds/stardew_valley/test/rules/TestSkills.py b/worlds/stardew_valley/test/rules/TestSkills.py index ee605bfaa1..a5957488a1 100644 --- a/worlds/stardew_valley/test/rules/TestSkills.py +++ b/worlds/stardew_valley/test/rules/TestSkills.py @@ -35,14 +35,13 @@ class TestSkillProgressionProgressive(SVTestBase): for level in range(1, 11): location_name = f"Level {level} {skill}" - location = self.multiworld.get_location(location_name, self.player) with self.subTest(location_name): if level > 1: - self.assert_cannot_reach_location(location, self.multiworld.state) + self.assert_cannot_reach_location(location_name) self.collect(f"{skill} Level") - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location(location_name) self.reset_collection_state() @@ -87,8 +86,7 @@ class TestSkillProgressionProgressiveWithMasteryWithoutMods(SVTestBase): for skill in all_vanilla_skills: with self.subTest(skill): - location = self.multiworld.get_location(f"{skill} Mastery", self.player) - self.assert_can_reach_location(location, self.multiworld.state) + self.assert_can_reach_location(f"{skill} Mastery") self.reset_collection_state() @@ -98,8 +96,7 @@ class TestSkillProgressionProgressiveWithMasteryWithoutMods(SVTestBase): self.collect_everything() self.remove_one_by_name(f"{skill} Level") - location = self.multiworld.get_location(f"{skill} Mastery", self.player) - self.assert_cannot_reach_location(location, self.multiworld.state) + self.assert_cannot_reach_location(f"{skill} Mastery") self.reset_collection_state() @@ -107,7 +104,6 @@ class TestSkillProgressionProgressiveWithMasteryWithoutMods(SVTestBase): self.collect_everything() self.remove_one_by_name(f"Progressive Pickaxe") - location = self.multiworld.get_location("Mining Mastery", self.player) - self.assert_cannot_reach_location(location, self.multiworld.state) + self.assert_cannot_reach_location("Mining Mastery") self.reset_collection_state() diff --git a/worlds/stardew_valley/test/rules/TestTools.py b/worlds/stardew_valley/test/rules/TestTools.py index 31dd581916..bda29e3d74 100644 --- a/worlds/stardew_valley/test/rules/TestTools.py +++ b/worlds/stardew_valley/test/rules/TestTools.py @@ -18,37 +18,37 @@ class TestProgressiveToolsLogic(SVTestBase): self.multiworld.state.prog_items = {1: Counter()} sturgeon_rule = self.world.logic.has("Sturgeon") - self.assert_rule_false(sturgeon_rule, self.multiworld.state) + self.assert_rule_false(sturgeon_rule) summer = self.create_item("Summer") self.multiworld.state.collect(summer) - self.assert_rule_false(sturgeon_rule, self.multiworld.state) + self.assert_rule_false(sturgeon_rule) fishing_rod = self.create_item("Progressive Fishing Rod") self.multiworld.state.collect(fishing_rod) self.multiworld.state.collect(fishing_rod) - self.assert_rule_false(sturgeon_rule, self.multiworld.state) + self.assert_rule_false(sturgeon_rule) fishing_level = self.create_item("Fishing Level") self.multiworld.state.collect(fishing_level) - self.assert_rule_false(sturgeon_rule, self.multiworld.state) + self.assert_rule_false(sturgeon_rule) self.multiworld.state.collect(fishing_level) self.multiworld.state.collect(fishing_level) self.multiworld.state.collect(fishing_level) self.multiworld.state.collect(fishing_level) self.multiworld.state.collect(fishing_level) - self.assert_rule_true(sturgeon_rule, self.multiworld.state) + self.assert_rule_true(sturgeon_rule) self.remove(summer) - self.assert_rule_false(sturgeon_rule, self.multiworld.state) + self.assert_rule_false(sturgeon_rule) winter = self.create_item("Winter") self.multiworld.state.collect(winter) - self.assert_rule_true(sturgeon_rule, self.multiworld.state) + self.assert_rule_true(sturgeon_rule) self.remove(fishing_rod) - self.assert_rule_false(sturgeon_rule, self.multiworld.state) + self.assert_rule_false(sturgeon_rule) def test_old_master_cannoli(self): self.multiworld.state.prog_items = {1: Counter()} @@ -58,35 +58,34 @@ class TestProgressiveToolsLogic(SVTestBase): self.multiworld.state.collect(self.create_item("Summer")) self.collect_lots_of_money() - rule = self.world.logic.region.can_reach_location("Old Master Cannoli") - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Old Master Cannoli") fall = self.create_item("Fall") self.multiworld.state.collect(fall) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Old Master Cannoli") tuesday = self.create_item("Traveling Merchant: Tuesday") self.multiworld.state.collect(tuesday) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Old Master Cannoli") rare_seed = self.create_item("Rare Seed") self.multiworld.state.collect(rare_seed) - self.assert_rule_true(rule, self.multiworld.state) + self.assert_can_reach_location("Old Master Cannoli") self.remove(fall) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Old Master Cannoli") self.remove(tuesday) green_house = self.create_item("Greenhouse") self.multiworld.state.collect(green_house) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Old Master Cannoli") friday = self.create_item("Traveling Merchant: Friday") self.multiworld.state.collect(friday) - self.assertTrue(self.multiworld.get_location("Old Master Cannoli", 1).access_rule(self.multiworld.state)) + self.assert_can_reach_location("Old Master Cannoli") self.remove(green_house) - self.assert_rule_false(rule, self.multiworld.state) + self.assert_cannot_reach_location("Old Master Cannoli") self.remove(friday) @@ -106,13 +105,13 @@ class TestToolVanillaRequiresBlacksmith(SVTestBase): for tool in [Tool.pickaxe, Tool.axe, Tool.hoe, Tool.trash_can, Tool.watering_can]: for material in [ToolMaterial.copper, ToolMaterial.iron, ToolMaterial.gold, ToolMaterial.iridium]: - self.assert_rule_false(self.world.logic.tool.has_tool(tool, material), self.multiworld.state) + self.assert_rule_false(self.world.logic.tool.has_tool(tool, material)) self.multiworld.state.collect(self.create_item(railroad_item)) for tool in [Tool.pickaxe, Tool.axe, Tool.hoe, Tool.trash_can, Tool.watering_can]: for material in [ToolMaterial.copper, ToolMaterial.iron, ToolMaterial.gold, ToolMaterial.iridium]: - self.assert_rule_true(self.world.logic.tool.has_tool(tool, material), self.multiworld.state) + self.assert_rule_true(self.world.logic.tool.has_tool(tool, material)) def test_cannot_get_fishing_rod_without_willy_access(self): railroad_item = "Railroad Boulder Removed" @@ -120,12 +119,12 @@ class TestToolVanillaRequiresBlacksmith(SVTestBase): self.collect_all_except(railroad_item) for fishing_rod_level in [3, 4]: - self.assert_rule_false(self.world.logic.tool.has_fishing_rod(fishing_rod_level), self.multiworld.state) + self.assert_rule_false(self.world.logic.tool.has_fishing_rod(fishing_rod_level)) self.multiworld.state.collect(self.create_item(railroad_item)) for fishing_rod_level in [3, 4]: - self.assert_rule_true(self.world.logic.tool.has_fishing_rod(fishing_rod_level), self.multiworld.state) + self.assert_rule_true(self.world.logic.tool.has_fishing_rod(fishing_rod_level)) def place_region_at_entrance(multiworld, player, region, entrance): From 4149db1a015c6d0a87d1f2b52883d946d6c7ee11 Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Tue, 8 Apr 2025 17:54:50 -0400 Subject: [PATCH 13/39] LADX: Stop using Utils.get_options (#4818) * init * use get * Update LinksAwakeningClient.py Co-authored-by: Fabian Dill * Update LinksAwakeningClient.py Co-authored-by: Aaron Wagener --------- Co-authored-by: Fabian Dill Co-authored-by: Aaron Wagener --- LinksAwakeningClient.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index bdfaa74625..69f50938d2 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -26,6 +26,7 @@ import typing from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, server_loop) from NetUtils import ClientStatus +from worlds.ladx import LinksAwakeningWorld from worlds.ladx.Common import BASE_ID as LABaseID from worlds.ladx.GpsTracker import GpsTracker from worlds.ladx.TrackerConsts import storage_key @@ -741,8 +742,8 @@ class LinksAwakeningContext(CommonContext): await asyncio.sleep(1.0) def run_game(romfile: str) -> None: - auto_start = typing.cast(typing.Union[bool, str], - Utils.get_options()["ladx_options"].get("rom_start", True)) + auto_start = LinksAwakeningWorld.settings.rom_start + if auto_start is True: import webbrowser webbrowser.open(romfile) From 98477e27aae99f6c4663a0fb11cb76d9581a51fc Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Tue, 8 Apr 2025 22:57:31 +0100 Subject: [PATCH 14/39] Core: Speed up fill_restrictive item_pool pop loop (#4536) * Core: Speed up fill_restrictive item_pool pop loop Items from `reachable_items` are placed in last-in-first-out order, so items being placed will be towards the end of `item_pool`, but the iteration to find the item was iterating from the start of `item_pool`. Now also uses `del` instead of `.pop()` for an additional, tiny, performance increase. It is unlikely for there to be a noticeable difference in most cases. Only generating with many worlds with a high percentage of progression items and fast access rules is likely to see a difference with this change. --skip_output generation of 400 template A Hat in Time yamls with progression balancing disabled goes from 76s to 43s (43% reduction) for me with this patch. This placed 43200 progression items out of 89974 items total (48% progression items). * Fix comment typo "be" was missing. --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- Fill.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Fill.py b/Fill.py index fe39b74fbe..cce7aec209 100644 --- a/Fill.py +++ b/Fill.py @@ -75,9 +75,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati items_to_place.append(reachable_items[next_player].pop()) for item in items_to_place: - for p, pool_item in enumerate(item_pool): + # The items added into `reachable_items` are placed starting from the end of each deque in + # `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`. + for p, pool_item in enumerate(reversed(item_pool), start=1): if pool_item is item: - item_pool.pop(p) + del item_pool[-p] break maximum_exploration_state = sweep_from_pool( From f2cb16a5bef78639b340e23d1bdde0660a24f70e Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Wed, 9 Apr 2025 01:38:46 +0200 Subject: [PATCH 15/39] CI: update action ubuntu build runners to 22.04 (#4847) --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b450fe46e..7529f693bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,8 +99,8 @@ jobs: if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough - build-ubuntu2004: - runs-on: ubuntu-20.04 + build-ubuntu2204: + runs-on: ubuntu-22.04 steps: # - copy code below to release.yml - - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f12e8fb80c..20d4d2fe32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,8 +29,8 @@ jobs: # build-release-windows: # this is done by hand because of signing # build-release-macos: # LF volunteer - build-release-ubuntu2004: - runs-on: ubuntu-20.04 + build-release-ubuntu2204: + runs-on: ubuntu-22.04 steps: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV From 0f7deb1d2a11c9f820d193c123052d1742d912f7 Mon Sep 17 00:00:00 2001 From: Zach 'Phar' Parks Date: Tue, 8 Apr 2025 23:46:46 -0500 Subject: [PATCH 16/39] WebHost: Remove styleController.js and replace functionality with HTML/CSS. (#4852) * ensure footer stays at bottom of page without JS * Remove some other usages. --- WebHostLib/static/assets/gameInfo.js | 1 - WebHostLib/static/assets/hostGame.js | 2 - WebHostLib/static/assets/styleController.js | 47 --------------------- WebHostLib/static/assets/tutorial.js | 1 - WebHostLib/static/styles/globalStyles.css | 7 +++ WebHostLib/templates/404.html | 2 +- WebHostLib/templates/hostGame.html | 3 +- WebHostLib/templates/landing.html | 2 +- WebHostLib/templates/pageWrapper.html | 29 +++++++------ WebHostLib/templates/seedError.html | 2 +- WebHostLib/templates/startPlaying.html | 3 +- WebHostLib/templates/viewSeed.html | 2 +- WebHostLib/templates/waitSeed.html | 2 +- 13 files changed, 30 insertions(+), 73 deletions(-) delete mode 100644 WebHostLib/static/assets/styleController.js diff --git a/WebHostLib/static/assets/gameInfo.js b/WebHostLib/static/assets/gameInfo.js index 1d6d136135..797c9f6448 100644 --- a/WebHostLib/static/assets/gameInfo.js +++ b/WebHostLib/static/assets/gameInfo.js @@ -23,7 +23,6 @@ window.addEventListener('load', () => { showdown.setOption('strikethrough', true); showdown.setOption('literalMidWordUnderscores', true); gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results); - adjustHeaderWidth(); // Reset the id of all header divs to something nicer for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { diff --git a/WebHostLib/static/assets/hostGame.js b/WebHostLib/static/assets/hostGame.js index db1ab1ddde..01a8da06e5 100644 --- a/WebHostLib/static/assets/hostGame.js +++ b/WebHostLib/static/assets/hostGame.js @@ -6,6 +6,4 @@ window.addEventListener('load', () => { document.getElementById('file-input').addEventListener('change', () => { document.getElementById('host-game-form').submit(); }); - - adjustFooterHeight(); }); diff --git a/WebHostLib/static/assets/styleController.js b/WebHostLib/static/assets/styleController.js deleted file mode 100644 index 924e86ee26..0000000000 --- a/WebHostLib/static/assets/styleController.js +++ /dev/null @@ -1,47 +0,0 @@ -const adjustFooterHeight = () => { - // If there is no footer on this page, do nothing - const footer = document.getElementById('island-footer'); - if (!footer) { return; } - - // If the body is taller than the window, also do nothing - if (document.body.offsetHeight > window.innerHeight) { - footer.style.marginTop = '0'; - return; - } - - // Add a margin-top to the footer to position it at the bottom of the screen - const sibling = footer.previousElementSibling; - const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight); - if (margin < 1) { - footer.style.marginTop = '0'; - return; - } - footer.style.marginTop = `${margin}px`; -}; - -const adjustHeaderWidth = () => { - // If there is no header, do nothing - const header = document.getElementById('base-header'); - if (!header) { return; } - - const tempDiv = document.createElement('div'); - tempDiv.style.width = '100px'; - tempDiv.style.height = '100px'; - tempDiv.style.overflow = 'scroll'; - tempDiv.style.position = 'absolute'; - tempDiv.style.top = '-500px'; - document.body.appendChild(tempDiv); - const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth; - document.body.removeChild(tempDiv); - - const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement; - const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0; - document.getElementById('base-header-right').style.marginRight = `${margin}px`; -}; - -window.addEventListener('load', () => { - window.addEventListener('resize', adjustFooterHeight); - window.addEventListener('resize', adjustHeaderWidth); - adjustFooterHeight(); - adjustHeaderWidth(); -}); diff --git a/WebHostLib/static/assets/tutorial.js b/WebHostLib/static/assets/tutorial.js index d527966005..c9022719fb 100644 --- a/WebHostLib/static/assets/tutorial.js +++ b/WebHostLib/static/assets/tutorial.js @@ -25,7 +25,6 @@ window.addEventListener('load', () => { showdown.setOption('literalMidWordUnderscores', true); showdown.setOption('disableForced4SpacesIndentedSublists', true); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); - adjustHeaderWidth(); const title = document.querySelector('h1') if (title) { diff --git a/WebHostLib/static/styles/globalStyles.css b/WebHostLib/static/styles/globalStyles.css index 1a0144830e..adcee6581b 100644 --- a/WebHostLib/static/styles/globalStyles.css +++ b/WebHostLib/static/styles/globalStyles.css @@ -36,6 +36,13 @@ html{ body{ margin: 0; + display: flex; + flex-direction: column; + min-height: calc(100vh - 110px); +} + +main { + flex-grow: 1; } a{ diff --git a/WebHostLib/templates/404.html b/WebHostLib/templates/404.html index 9d567510ee..6c91fed4ac 100644 --- a/WebHostLib/templates/404.html +++ b/WebHostLib/templates/404.html @@ -1,5 +1,6 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} Page Not Found (404) @@ -13,5 +14,4 @@ The page you're looking for doesn't exist.
Click here to return to safety. - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/hostGame.html b/WebHostLib/templates/hostGame.html index 2bcb993af5..d7d0a96331 100644 --- a/WebHostLib/templates/hostGame.html +++ b/WebHostLib/templates/hostGame.html @@ -1,4 +1,5 @@ {% extends 'pageWrapper.html' %} +{% set show_footer = True %} {% block head %} Upload Multidata @@ -27,6 +28,4 @@ - - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/landing.html b/WebHostLib/templates/landing.html index b489ef18ac..e7d0569e6c 100644 --- a/WebHostLib/templates/landing.html +++ b/WebHostLib/templates/landing.html @@ -1,4 +1,5 @@ {% extends 'pageWrapper.html' %} +{% set show_footer = True %} {% block head %} Archipelago @@ -57,5 +58,4 @@ - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/pageWrapper.html b/WebHostLib/templates/pageWrapper.html index c7dda523ef..4347b4add3 100644 --- a/WebHostLib/templates/pageWrapper.html +++ b/WebHostLib/templates/pageWrapper.html @@ -5,26 +5,29 @@ - {% block head %} Archipelago {% endblock %} +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages | unique %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} -{% with messages = get_flashed_messages() %} - {% if messages %} -
- {% for message in messages | unique %} -
{{ message }}
- {% endfor %} -
+ {% block body %} + {% endblock %} +
+ + {% if show_footer %} + {% include "islandFooter.html" %} {% endif %} -{% endwith %} - -{% block body %} -{% endblock %} - diff --git a/WebHostLib/templates/seedError.html b/WebHostLib/templates/seedError.html index 0f5850da1b..a5eec1a4cc 100644 --- a/WebHostLib/templates/seedError.html +++ b/WebHostLib/templates/seedError.html @@ -1,5 +1,6 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} Generation failed, please retry. @@ -15,5 +16,4 @@ {{ seed_error }} - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/startPlaying.html b/WebHostLib/templates/startPlaying.html index ab2f021d61..9e09474bd0 100644 --- a/WebHostLib/templates/startPlaying.html +++ b/WebHostLib/templates/startPlaying.html @@ -1,4 +1,5 @@ {% extends 'pageWrapper.html' %} +{% set show_footer = True %} {% block head %} Start Playing @@ -26,6 +27,4 @@

- - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/viewSeed.html b/WebHostLib/templates/viewSeed.html index a8478c95c3..70ffe23b7b 100644 --- a/WebHostLib/templates/viewSeed.html +++ b/WebHostLib/templates/viewSeed.html @@ -1,5 +1,6 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} View Seed {{ seed.id|suuid }} @@ -50,5 +51,4 @@ - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/waitSeed.html b/WebHostLib/templates/waitSeed.html index 9041b901b5..235c2f1665 100644 --- a/WebHostLib/templates/waitSeed.html +++ b/WebHostLib/templates/waitSeed.html @@ -1,5 +1,6 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} Generation in Progress @@ -15,5 +16,4 @@ Waiting for game to generate, this page auto-refreshes to check. - {% include 'islandFooter.html' %} {% endblock %} From e211dfa1c2f6da28c17353749e319ec8a4ec7a4f Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Wed, 9 Apr 2025 07:43:28 +0200 Subject: [PATCH 17/39] WebHost: use JS to refresh waitSeed if scripting is enabled (#4843) --- WebHostLib/templates/waitSeed.html | 34 +++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/WebHostLib/templates/waitSeed.html b/WebHostLib/templates/waitSeed.html index 235c2f1665..f2729353a6 100644 --- a/WebHostLib/templates/waitSeed.html +++ b/WebHostLib/templates/waitSeed.html @@ -4,7 +4,9 @@ {% block head %} Generation in Progress - + {% endblock %} @@ -16,4 +18,34 @@ Waiting for game to generate, this page auto-refreshes to check. + {% endblock %} From f93734f9e3b7b805bff4edacc204fbf31559e345 Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:20:56 -0400 Subject: [PATCH 18/39] Pokemon Red and Blue: PC Item Fix (#4835) * Pokemon Red and Blue PC Item fix * Respect non_local_items for PC Item * prefer exclude if also in priority locations --------- Co-authored-by: alchav --- worlds/pokemon_rb/regions.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/worlds/pokemon_rb/regions.py b/worlds/pokemon_rb/regions.py index a7c0b6d533..84c9b25735 100644 --- a/worlds/pokemon_rb/regions.py +++ b/worlds/pokemon_rb/regions.py @@ -1580,16 +1580,22 @@ def create_regions(world): world.random.shuffle(world.item_pool) if not world.options.key_items_only: - if "Player's House 2F - Player's PC" in world.options.exclude_locations: - acceptable_item = lambda item: item.excludable - elif "Player's House 2F - Player's PC" in world.options.priority_locations: - acceptable_item = lambda item: item.advancement - else: - acceptable_item = lambda item: True + def acceptable_item(item): + return ("Badge" not in item.name and "Trap" not in item.name and item.name != "Pokedex" + and "Coins" not in item.name and "Progressive" not in item.name + and ("Player's House 2F - Player's PC" not in world.options.exclude_locations or item.excludable) + and ("Player's House 2F - Player's PC" in world.options.exclude_locations or + "Player's House 2F - Player's PC" not in world.options.priority_locations or item.advancement)) for i, item in enumerate(world.item_pool): - if acceptable_item(item): + if acceptable_item(item) and (item.name not in world.options.non_local_items.value): world.pc_item = world.item_pool.pop(i) break + else: + for i, item in enumerate(world.item_pool): + if acceptable_item(item): + world.pc_item = world.item_pool.pop(i) + break + advancement_items = [item.name for item in world.item_pool if item.advancement] \ + [item.name for item in world.multiworld.precollected_items[world.player] if From 1ee749b3522735bdd7f250a69d7b3a2a1b19d4a4 Mon Sep 17 00:00:00 2001 From: Ziktofel Date: Wed, 9 Apr 2025 22:21:16 +0200 Subject: [PATCH 19/39] SC2 Client: Fix missing mission tooltip after KivyMD switch (#4827) --- worlds/sc2/ClientGui.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/worlds/sc2/ClientGui.py b/worlds/sc2/ClientGui.py index 27857715a8..d16acad83d 100644 --- a/worlds/sc2/ClientGui.py +++ b/worlds/sc2/ClientGui.py @@ -5,12 +5,11 @@ from NetUtils import JSONMessagePart from kvui import GameManager, HoverBehavior, ServerToolTip, KivyJSONtoTextParser from kivy.app import App from kivy.clock import Clock -from kivymd.uix.tab import MDTabsItem, MDTabsItemText from kivy.uix.gridlayout import GridLayout from kivy.lang import Builder from kivy.uix.label import Label from kivy.uix.button import Button -from kivy.uix.floatlayout import FloatLayout +from kivymd.uix.tooltip import MDTooltip from kivy.uix.scrollview import ScrollView from kivy.properties import StringProperty @@ -26,30 +25,22 @@ class HoverableButton(HoverBehavior, Button): pass -class MissionButton(HoverableButton): +class MissionButton(HoverableButton, MDTooltip): tooltip_text = StringProperty("Test") def __init__(self, *args, **kwargs): - super(HoverableButton, self).__init__(*args, **kwargs) - self.layout = FloatLayout() - self.popuplabel = ServerToolTip(text=self.text, markup=True) - self.popuplabel.padding = [5, 2, 5, 2] - self.layout.add_widget(self.popuplabel) + super(HoverableButton, self).__init__(**kwargs) + self._tooltip = ServerToolTip(text=self.text, markup=True) + self._tooltip.padding = [5, 2, 5, 2] def on_enter(self): - self.popuplabel.text = self.tooltip_text + self._tooltip.text = self.tooltip_text - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - if self.tooltip_text == "": - self.ctx.current_tooltip = None - else: - App.get_running_app().root.add_widget(self.layout) - self.ctx.current_tooltip = self.layout + if self.tooltip_text != "": + self.display_tooltip() def on_leave(self): - self.ctx.ui.clear_tooltip() + self.remove_tooltip() @property def ctx(self) -> SC2Context: From b7263edfd0512949c0dbfef86e1e48bd43c13890 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Wed, 9 Apr 2025 19:41:07 -0400 Subject: [PATCH 20/39] Lingo: Removed unnecessary "global" keywords (#4854) --- worlds/lingo/items.py | 2 -- worlds/lingo/locations.py | 2 -- worlds/lingo/utils/pickle_static_data.py | 21 ++------------------- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/worlds/lingo/items.py b/worlds/lingo/items.py index b773caeb4e..338ddffa5d 100644 --- a/worlds/lingo/items.py +++ b/worlds/lingo/items.py @@ -58,8 +58,6 @@ def get_prog_item_classification(item_name: str): def load_item_data(): - global ALL_ITEM_TABLE, ITEMS_BY_GROUP - for color in ["Black", "Red", "Blue", "Yellow", "Green", "Orange", "Gray", "Brown", "Purple"]: ALL_ITEM_TABLE[color] = ItemData(get_special_item_id(color), get_prog_item_classification(color), ItemType.COLOR, False, []) diff --git a/worlds/lingo/locations.py b/worlds/lingo/locations.py index c527e522fb..bcd14fc5a7 100644 --- a/worlds/lingo/locations.py +++ b/worlds/lingo/locations.py @@ -35,8 +35,6 @@ LOCATIONS_BY_GROUP: Dict[str, List[str]] = {} def load_location_data(): - global ALL_LOCATION_TABLE, LOCATIONS_BY_GROUP - for room_name, panels in PANELS_BY_ROOM.items(): for panel_name, panel in panels.items(): location_name = f"{room_name} - {panel_name}" if panel.location_name is None else panel.location_name diff --git a/worlds/lingo/utils/pickle_static_data.py b/worlds/lingo/utils/pickle_static_data.py index df82a12861..740e129bcb 100644 --- a/worlds/lingo/utils/pickle_static_data.py +++ b/worlds/lingo/utils/pickle_static_data.py @@ -58,8 +58,7 @@ def hash_file(path): def load_static_data(ll1_path, ids_path): - global PAINTING_EXITS, SPECIAL_ITEM_IDS, PANEL_LOCATION_IDS, DOOR_LOCATION_IDS, DOOR_ITEM_IDS, \ - DOOR_GROUP_ITEM_IDS, PROGRESSIVE_ITEM_IDS, PANEL_DOOR_ITEM_IDS, PANEL_GROUP_ITEM_IDS + global PAINTING_EXITS # Load in all item and location IDs. These are broken up into groups based on the type of item/location. with open(ids_path, "r") as file: @@ -128,7 +127,7 @@ def load_static_data(ll1_path, ids_path): def process_single_entrance(source_room: str, room_name: str, door_obj) -> RoomEntrance: - global PAINTING_ENTRANCES, PAINTING_EXIT_ROOMS + global PAINTING_ENTRANCES entrance_type = EntranceType.NORMAL if "painting" in door_obj and door_obj["painting"]: @@ -175,8 +174,6 @@ def process_entrance(source_room, doors, room_obj): def process_panel_door(room_name, panel_door_name, panel_door_data): - global PANEL_DOORS_BY_ROOM, PANEL_DOOR_BY_PANEL_BY_ROOM - panels: List[RoomAndPanel] = list() for panel in panel_door_data["panels"]: if isinstance(panel, dict): @@ -215,8 +212,6 @@ def process_panel_door(room_name, panel_door_name, panel_door_data): def process_panel(room_name, panel_name, panel_data): - global PANELS_BY_ROOM - # required_room can either be a single room or a list of rooms. if "required_room" in panel_data: if isinstance(panel_data["required_room"], list): @@ -310,8 +305,6 @@ def process_panel(room_name, panel_name, panel_data): def process_door(room_name, door_name, door_data): - global DOORS_BY_ROOM - # The item name associated with a door can be explicitly specified in the configuration. If it is not, it is # generated from the room and door name. if "item_name" in door_data: @@ -409,8 +402,6 @@ def process_door(room_name, door_name, door_data): def process_painting(room_name, painting_data): - global PAINTINGS, REQUIRED_PAINTING_ROOMS, REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS - # Read in information about this painting and store it in an object. painting_id = painting_data["id"] @@ -468,8 +459,6 @@ def process_painting(room_name, painting_data): def process_sunwarp(room_name, sunwarp_data): - global SUNWARP_ENTRANCES, SUNWARP_EXITS - if sunwarp_data["direction"] == "enter": SUNWARP_ENTRANCES[sunwarp_data["dots"] - 1] = room_name else: @@ -477,8 +466,6 @@ def process_sunwarp(room_name, sunwarp_data): def process_progressive_door(room_name, progression_name, progression_doors): - global PROGRESSIVE_ITEMS, PROGRESSIVE_DOORS_BY_ROOM - # Progressive items are configured as a list of doors. PROGRESSIVE_ITEMS.add(progression_name) @@ -497,8 +484,6 @@ def process_progressive_door(room_name, progression_name, progression_doors): def process_progressive_panel(room_name, progression_name, progression_panel_doors): - global PROGRESSIVE_ITEMS, PROGRESSIVE_PANELS_BY_ROOM - # Progressive items are configured as a list of panel doors. PROGRESSIVE_ITEMS.add(progression_name) @@ -517,8 +502,6 @@ def process_progressive_panel(room_name, progression_name, progression_panel_doo def process_room(room_name, room_data): - global ALL_ROOMS - room_obj = Room(room_name, []) if "entrances" in room_data: From e3b8a60584524e5dbb24cfc1542a3acb92da9360 Mon Sep 17 00:00:00 2001 From: qwint Date: Wed, 9 Apr 2025 20:29:11 -0500 Subject: [PATCH 21/39] Webhost: Fix Sphere Tracker crashing on item links (#4855) --- BaseClasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BaseClasses.py b/BaseClasses.py index 4db3917985..4074108b4b 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -616,7 +616,7 @@ class MultiWorld(): locations: Set[Location] = set() events: Set[Location] = set() for location in self.get_filled_locations(): - if type(location.item.code) is int: + if type(location.item.code) is int and type(location.address) is int: locations.add(location) else: events.add(location) From 78c93d7e3905d932d284c72c525911fe1f731fe8 Mon Sep 17 00:00:00 2001 From: qwint Date: Thu, 10 Apr 2025 12:00:48 -0500 Subject: [PATCH 22/39] Docs: Add FAQ section for corrupted metadata debugging (#4705) Co-authored-by: Scipio Wright --- docs/apworld_dev_faq.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/apworld_dev_faq.md b/docs/apworld_dev_faq.md index 769a2fb3a0..6d7d23b488 100644 --- a/docs/apworld_dev_faq.md +++ b/docs/apworld_dev_faq.md @@ -66,3 +66,22 @@ The reason entrance access rules using `location.can_reach` and `entrance.can_re We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules. As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost. Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance → region dependencies, making indirect conditions preferred because they are much faster. + +--- + +### I uploaded the generated output of my world to the webhost and webhost is erroring on corrupted multidata + +The error `Could not load multidata. File may be corrupted or incompatible.` occurs when uploading a locally generated +file where there is an issue with the multidata contained within it. It may come with a description like +`(No module named 'worlds.myworld')` or `(global 'worlds.myworld.names.ItemNames' is forbidden)` + +Pickling is a way to compress python objects such that they can be decompressed and be used to rebuild the +python objects. This means that if one of your custom class instances ends up in the multidata, the server would not +be able to load that custom class to decompress the data, which can fail either because the custom class is unknown +(because it cannot load your world module) or the class it's attempting to import to decompress is deemed unsafe. + +Common situations where this can happen include: +* Using Option instances directly in slot_data. Ex: using `options.option_name` instead of `options.option_name.value`. + Also, consider using the `options.as_dict("option_name", "option_two")` helper. +* Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`, + make sure that you are not using your enum class for either the names or ids in these mappings. From 399958c8814112ed266eef5734999544a9337da3 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Thu, 10 Apr 2025 12:03:05 -0500 Subject: [PATCH 23/39] The Messenger: Add an FAQ (#4718) --- worlds/messenger/docs/en_The Messenger.md | 20 ++++++++++++++++++-- worlds/messenger/options.py | 4 +++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md index a68ee5ba4c..1886162fc9 100644 --- a/worlds/messenger/docs/en_The Messenger.md +++ b/worlds/messenger/docs/en_The Messenger.md @@ -1,6 +1,7 @@ # The Messenger ## Quick Links + - [Setup](/tutorial/The%20Messenger/setup/en) - [Options Page](/games/The%20Messenger/player-options) - [Courier Github](https://github.com/Brokemia/Courier) @@ -26,6 +27,7 @@ obtained. You'll be forced to do sections of the game in different ways with you ## Where can I find items? You can find items wherever items can be picked up in the original game. This includes: + * Shopkeeper dialog where the player originally gains movement items * Quest Item pickups * Music Box notes @@ -42,6 +44,7 @@ group of items. Hinting for a group will choose a random item from the group tha 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 @@ -64,16 +67,29 @@ The groups you can use for The Messenger are: 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. * 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 * 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. ## What do I do if I have a problem? If you believe something happened that isn't intended, please get the `log.txt` from the folder of your game installation and send a bug report either on GitHub or the [Archipelago Discord Server](http://archipelago.gg/discord) + +## FAQ + +* The tracker says I can get some checks in Howling Grotto, but I can't defeat the Emerald Golem. How do I get there? + * Due to the way the vanilla game handles bosses and level transitions, if you die to him, the room will be unlocked, + and you can leave. +* I have the money wrench. Why won't the shopkeeper let me enter the sink? + * The money wrench is both an item you must find or receive from another player and a location check, which you must + purchase from the Artificer, as in vanilla. +* How do I unfreeze Manfred? Where is the monk? + * The monk will only appear near Manfred after you cleanse the Queen of Quills with the fairy (magic firefly). +* I have all the power seals I need to win, but nothing is happening when I open the chest. + * Due to how the level loading code works, I am currently unable to teleport you out of HQ at will; you must enter the + shop from within a level. diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index c7a0f543ba..aaf152fbf8 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -147,7 +147,9 @@ class MusicBox(DefaultOnToggle): class NotesNeeded(Range): - """How many notes are needed to access the Music Box.""" + """ + How many notes need to be found in order to access the Music Box. 6 are always needed to enter, so this places the others in your start inventory. + """ display_name = "Notes Needed" range_start = 1 range_end = 6 From 50fd42d0c2ade7fdd071c158cc172c42a416176e Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Thu, 10 Apr 2025 12:13:38 -0500 Subject: [PATCH 24/39] The Messenger: Add a plando guide (#4719) --- worlds/messenger/__init__.py | 10 ++- worlds/messenger/docs/plando_en.md | 101 +++++++++++++++++++++++++++++ worlds/messenger/options.py | 22 +------ 3 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 worlds/messenger/docs/plando_en.md diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py index 8bde3bbc7a..2382a46c31 100644 --- a/worlds/messenger/__init__.py +++ b/worlds/messenger/__init__.py @@ -46,8 +46,16 @@ class MessengerWeb(WebWorld): "setup/en", ["alwaysintreble"], ) + plando_en = Tutorial( + "The Messenger Plando Guide", + "A guide detailing The Messenger's various supported plando options.", + "English", + "plando_en.md", + "plando/en", + ["alwaysintreble"], + ) - tutorials = [tut_en] + tutorials = [tut_en, plando_en] class MessengerWorld(World): diff --git a/worlds/messenger/docs/plando_en.md b/worlds/messenger/docs/plando_en.md new file mode 100644 index 0000000000..920cf029d8 --- /dev/null +++ b/worlds/messenger/docs/plando_en.md @@ -0,0 +1,101 @@ +# The Messenger Plando Guide + +This guide details the usage of the game-specific plando options that The Messenger has. The Messenger also supports the +generic item plando. For more information on what plando is and for information covering item plando, refer to the +[generic Archipelago plando guide](/tutorial/Archipelago/plando/en). The Messenger also uses the generic connection +plando system, but with specific behaviors that will be covered in this guide along with the other options. + +## Shop Price Plando + +This option allows you to specify prices for items in both shops. This also supports weighting, allowing you to choose +from multiple different prices for any given item. + +### Example + +```yaml +The Messenger: + shop_price_plan: + Karuta Plates: 50 + Devil's Due: 1 + Barmath'azel Figurine: + # left side is price, right side is weight + 500: 10 + 700: 5 + 1000: 20 +``` + +This block will make the item at the `Karuta Plates` node cost 50 shards, `Devil's Due` will cost 1 shard, and +`Barmath'azel Figurine` will cost either 500, 700, or 1000, with 1000 being the most likely with a 20/35 chance. + +## Portal Plando + +This option allows you to specify certain outputs for the portals. This option will only be checked if portal shuffle +and the `connections` plando host setting are enabled. + +A portal connection is plandoed by specifying an `entrance` and an `exit`. This option also supports `percentage`, which +is the percent chance that that connection occurs. The `entrance` is which portal is going to be entered, whereas the +`exit` is where the portal will lead and can include a shop location, a checkpoint, or any portal. However, the +portal exit must also be in the available pool for the selected portal shuffle option. For example, if portal shuffle is +set to `shops`, then the valid exits will only be portals and shops; any exit that is a checkpoint will not be valid. If +portal shuffle is set to `checkpoints`, you may not have multiple portals lead to the same area, e.g. `Seashell` and +`Spike Wave` may not both be used since they are both in Quillshroom Marsh. If the option is set to `anywhere`, then all +exits are valid. + +All valid connections for portal shuffle can be found by scrolling through the [portals module](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/portals.py#L12). +The entrance and exit should be written exactly as they appear within that file, except for when the **exit** point is a +portal. In that case, it should have "Portal" included. + +### Example + +```yaml +The Messenger: + portal_plando: + - entrance: Riviere Turquoise + exit: Wingsuit + - entrance: Sunken Shrine + exit: Sunny Day + - entrance: Searing Crags + exit: Glacial Peak Portal +``` + +This block will make it so that the Riviere Turquoise Portal will exit to the Wingsuit Shop, the Sunken Shrine Portal +will exit to the Sunny Day checkpoint, and the Searing Crags Portal will exit to the Glacial Peak Portal. + +## Transition Plando + +This option allows you to specify certain connections when using transition shuffle. This will only work if +transition shuffle and the `connections` plando host setting are enabled. + +Each transition connection is plandoed by specifying its attributes: + +* `entrance` is where you will enter this transition from. +* `exit` is where the transition will lead. +* `percentage` is the chance this connection will happen at all. +* `direction` is used to specify whether this connection will also go in reverse. This entry will be ignored if the + transition shuffle is set to `coupled` or if the specified connection can only occur in one direction, such as exiting + to Riviere Turquoise. The default direction is "both", which will make it so that returning through the exit + transition will return you to where you entered it from. "entrance" and "exit" are treated the same, with them both + making this transition only one-way. + +Valid connections can be found in the [`RANDOMIZED_CONNECTIONS` dictionary](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L640). +The keys (left) are entrances, and values (right) are exits. Whether you want the connection to go both ways or not, +both sides must either be two-way or one-way; E.g. connecting Artificer (Corrupted Future Portal) to one of the +Quillshroom Marsh entrances is not a valid pairing. A pairing can be determined to be two-way if both the entrance and +exit of that pair are an exit and entrance of another pairing, respectively. + +### Example + +```yaml +The Messenger: + plando_connections: + - entrance: Searing Crags - Top + exit: Dark Cave - Right + - entrance: Glacial Peak - Left + exit: Corrupted Future +``` + +This block will create the following connections: +1. Leaving Searing Crags towards Glacial Peak will take you to the beginning of Dark Cave, and leaving the Dark Cave + door will return you to the top of Searing Crags. +2. Taking Manfred to leave Glacial Peak, will take you to Corrupted Future. There is no reverse connection here so it + will always be one-way. diff --git a/worlds/messenger/options.py b/worlds/messenger/options.py index aaf152fbf8..6b04118893 100644 --- a/worlds/messenger/options.py +++ b/worlds/messenger/options.py @@ -16,17 +16,8 @@ class MessengerAccessibility(ItemsAccessibility): class PortalPlando(PlandoConnections): """ - Plando connections to be used with portal shuffle. Direction is ignored. - List of valid connections can be found here: https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/portals.py#L12. - The entering Portal should *not* have "Portal" appended. - For the exits, those in checkpoints and shops should just be the name of the spot, while portals should have " Portal" at the end. - Example: - - entrance: Riviere Turquoise - exit: Wingsuit - - entrance: Sunken Shrine - exit: Sunny Day - - entrance: Searing Crags - exit: Glacial Peak Portal + Plando connections to be used with portal shuffle. + Documentation on using this can be found in The Messenger plando guide. """ display_name = "Portal Plando Connections" portals = [f"{portal} Portal" for portal in PORTALS] @@ -40,14 +31,7 @@ class PortalPlando(PlandoConnections): class TransitionPlando(PlandoConnections): """ Plando connections to be used with transition shuffle. - List of valid connections can be found at https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L641. - Dictionary keys (left) are entrances and values (right) are exits. If transition shuffle is on coupled all plando - connections will be coupled. If on decoupled, "entrance" and "exit" will be treated the same, simply making the - plando connection one-way from entrance to exit. - Example: - - entrance: Searing Crags - Top - exit: Dark Cave - Right - direction: both + Documentation on using this can be found in The Messenger plando guide. """ display_name = "Transition Plando Connections" entrances = frozenset(RANDOMIZED_CONNECTIONS.keys()) From 1fd8e4435ec46c8c03140804ba5a7a323e1850e4 Mon Sep 17 00:00:00 2001 From: Carter Hesterman Date: Thu, 10 Apr 2025 11:19:03 -0600 Subject: [PATCH 25/39] Civ 6: Update setup documentation to account for common pitfalls (#4797) --- worlds/civ_6/docs/en_Civilization VI.md | 2 +- worlds/civ_6/docs/setup_en.md | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/worlds/civ_6/docs/en_Civilization VI.md b/worlds/civ_6/docs/en_Civilization VI.md index 3b1fbbdb05..215da00aa4 100644 --- a/worlds/civ_6/docs/en_Civilization VI.md +++ b/worlds/civ_6/docs/en_Civilization VI.md @@ -51,7 +51,7 @@ Boosts have logic associated with them in order to verify you can always reach t - I need to kill a unit with a slinger/archer/musketman or some other obsolete unit I can't build anymore, how can I do this? - Don't forget you can go into the Tech Tree and click on a Vanilla tech you've received in order to toggle it on/off. This is necessary in order to pursue some of the boosts if you receive techs in certain orders. - Something happened, and I'm not able to unlock the boost due to game rules! - - A few scenarios you may worry about: "Found a religion", "Make an alliance with another player", "Develop an alliance to level 2", "Build a wonder from X Era", to name a few. Any boost that is "miss-able" has been flagged as an "Excluded" location and will not ever receive a progression item. For a list of how each boost is flagged, take a look [here](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/boosts.json). + - A few scenarios you may worry about: "Found a religion", "Make an alliance with another player", "Develop an alliance to level 2", "Build a wonder from X Era", to name a few. Any boost that is "miss-able" has been flagged as an "Excluded" location and will not ever receive a progression item. For a list of how each boost is flagged, take a look [here](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/boosts.py). - I'm worried that my `PROGRESSIVE_ERA` item is going to be stuck in a boost I won't have time to complete before my maximum unlocked era ends! - The unpredictable timing of boosts and unlocking them can occasionally lead to scenarios where you'll have to first encounter a locked era defeat and then load a previous save. To help reduce the frequency of this, local `PROGRESSIVE_ERA` items will never be located at a boost check. - There's too many boosts, how will I know which one's I should focus on?! diff --git a/worlds/civ_6/docs/setup_en.md b/worlds/civ_6/docs/setup_en.md index 09f6ff55c5..9cf4744b65 100644 --- a/worlds/civ_6/docs/setup_en.md +++ b/worlds/civ_6/docs/setup_en.md @@ -14,22 +14,17 @@ The following are required in order to play Civ VI in Archipelago: ## Enabling the tuner -Depending on how you installed Civ 6 you will have to navigate to one of the following: - -- `YOUR_USER/Documents/My Games/Sid Meier's Civilization VI/AppOptions.txt` -- `YOUR_USER/AppData/Local/Firaxis Games/Sid Meier's Civilization VI/AppOptions.txt` - -Once you have located your `AppOptions.txt`, do a search for `Enable FireTuner`. Set `EnableTuner` to `1` instead of `0`. **NOTE**: While this is active, achievements will be disabled. +In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled. ## Mod Installation 1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest). -2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. +2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure. 3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it. -4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. +4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can just rename it to a file ending with `.zip` and extract its contents to a new folder. To do this, right click the `.apcivvi` file and click "Rename", make sure it ends in `.zip`, then right click it again and select "Extract All". 5. Your finished mod folder should look something like this: From 934b09238ea5f0ce7b699c902cd95123e5a9a152 Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Thu, 10 Apr 2025 13:21:33 -0400 Subject: [PATCH 26/39] Docs: Update to adding games.md (#4816) --- docs/adding games.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/adding games.md b/docs/adding games.md index fbbd798859..2decb667b7 100644 --- a/docs/adding games.md +++ b/docs/adding games.md @@ -60,7 +60,7 @@ These are "nice to have" features for a client, but they are not strictly requir if possible. * If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from - other clients. The icon size is 38x38 pixels, but it will accept larger images with downscaling. + other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size. ## World @@ -109,6 +109,10 @@ subclass for webhost documentation and behaviors * A non-zero number of locations, added to your regions * A non-zero number of items **equal** to the number of locations, added to the multiworld itempool * In rare cases, there may be 0-location-0-item games, but this is extremely atypical. +* A set + [completion condition](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#L77) (aka "goal") for + the player. + * Use your player as the index (`multiworld.completion_condition[player]`) for your world's completion goal. ### Encouraged Features @@ -145,8 +149,8 @@ workarounds or preferred methods which should be used instead: * It is discouraged to use `yaml.load` directly due to security concerns. * When possible, use `Utils.yaml_load` instead, as this defaults to the safe loader. * When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively), - Do **not** use `=` as this will overwrite all elements for all games in the seed. - * Instead, use `append`, `extend`, or `+=`. + do **not** use `=` as this will overwrite all elements for all games in the seed. + * Instead, use `append`, `extend`, or `+=`. ### Notable Caveats From 879d7c23b796ce281354add5be4211bd27852796 Mon Sep 17 00:00:00 2001 From: qwint Date: Thu, 10 Apr 2025 13:18:43 -0500 Subject: [PATCH 27/39] HK: Workaround for NamedRange webhost bug (#4819) --- worlds/hk/Options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index e76e7eba9d..fbfdbfa871 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -450,7 +450,7 @@ class GrubHuntGoal(NamedRange): display_name = "Grub Hunt Goal" range_start = 1 range_end = 46 - special_range_names = {"all": -1} + special_range_names = {"all": -1, "forty_six": 46} default = 46 From ee471a48bd443b3663100efa6044dce88bfc3679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Bolduc?= <16137441+Jouramie@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:34:21 -0400 Subject: [PATCH 28/39] Stardew Valley: Fix some determinism issues with entrance rando when playing with mods (#4812) --- worlds/stardew_valley/content/__init__.py | 2 +- worlds/stardew_valley/region_classes.py | 2 +- worlds/stardew_valley/regions.py | 5 ++--- .../stardew_valley/test/stability/StabilityOutputScript.py | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/worlds/stardew_valley/content/__init__.py b/worlds/stardew_valley/content/__init__.py index 33608531d9..5f7d4a5fc5 100644 --- a/worlds/stardew_valley/content/__init__.py +++ b/worlds/stardew_valley/content/__init__.py @@ -21,7 +21,7 @@ def choose_content_packs(player_options: options.StardewValleyOptions): if player_options.special_order_locations & options.SpecialOrderLocations.value_qi: active_packs.append(content_packs.qi_board_content_pack) - for mod in player_options.mods.value: + for mod in sorted(player_options.mods.value): active_packs.append(content_packs.by_mod[mod]) return active_packs diff --git a/worlds/stardew_valley/region_classes.py b/worlds/stardew_valley/region_classes.py index bd64518ea1..d3d16e3878 100644 --- a/worlds/stardew_valley/region_classes.py +++ b/worlds/stardew_valley/region_classes.py @@ -34,7 +34,7 @@ class RegionData: merged_exits.extend(self.exits) if exits is not None: merged_exits.extend(exits) - merged_exits = list(set(merged_exits)) + merged_exits = sorted(set(merged_exits)) return RegionData(self.name, merged_exits, is_ginger_island=self.is_ginger_island) def get_without_exits(self, exits_to_remove: Set[str]): diff --git a/worlds/stardew_valley/regions.py b/worlds/stardew_valley/regions.py index 7a680d5faa..d5be53ba86 100644 --- a/worlds/stardew_valley/regions.py +++ b/worlds/stardew_valley/regions.py @@ -521,7 +521,7 @@ def create_final_regions(world_options) -> List[RegionData]: final_regions.extend(vanilla_regions) if world_options.mods is None: return final_regions - for mod in world_options.mods.value: + for mod in sorted(world_options.mods.value): if mod not in ModDataList: continue for mod_region in ModDataList[mod].regions: @@ -747,8 +747,7 @@ def swap_one_random_connection(regions_by_name, connections_by_name, randomized_ randomized_connections_already_shuffled = {connection: randomized_connections[connection] for connection in randomized_connections if connection != randomized_connections[connection]} - unreachable_regions_names_leading_somewhere = tuple([region for region in unreachable_regions - if len(regions_by_name[region].exits) > 0]) + unreachable_regions_names_leading_somewhere = [region for region in sorted(unreachable_regions) if len(regions_by_name[region].exits) > 0] unreachable_regions_leading_somewhere = [regions_by_name[region_name] for region_name in unreachable_regions_names_leading_somewhere] unreachable_regions_exits_names = [exit_name for region in unreachable_regions_leading_somewhere for exit_name in region.exits] unreachable_connections = [connections_by_name[exit_name] for exit_name in unreachable_regions_exits_names] diff --git a/worlds/stardew_valley/test/stability/StabilityOutputScript.py b/worlds/stardew_valley/test/stability/StabilityOutputScript.py index a5385362b7..9b4b608d4e 100644 --- a/worlds/stardew_valley/test/stability/StabilityOutputScript.py +++ b/worlds/stardew_valley/test/stability/StabilityOutputScript.py @@ -2,7 +2,7 @@ import argparse import json from .. import setup_solo_multiworld -from ..options.presets import allsanity_mods_6_x_x +from ..options.presets import allsanity_mods_6_x_x_exclude_disabled from ...options import FarmType, EntranceRandomization if __name__ == "__main__": @@ -12,7 +12,7 @@ if __name__ == "__main__": args = parser.parse_args() seed = args.seed - options = allsanity_mods_6_x_x() + options = allsanity_mods_6_x_x_exclude_disabled() options[FarmType.internal_name] = FarmType.option_standard options[EntranceRandomization.internal_name] = EntranceRandomization.option_buildings multi_world = setup_solo_multiworld(options, seed=seed) From 6a9299018c7829f5ac922ed0805ccc9e1bb521e2 Mon Sep 17 00:00:00 2001 From: Mysteryem Date: Fri, 11 Apr 2025 02:17:28 +0100 Subject: [PATCH 29/39] MLSS: Fix generation error with emblem hunt and no digspots (#4859) --- worlds/mlss/Rules.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/worlds/mlss/Rules.py b/worlds/mlss/Rules.py index 6592a805d5..848f00cb63 100644 --- a/worlds/mlss/Rules.py +++ b/worlds/mlss/Rules.py @@ -148,12 +148,13 @@ def set_rules(world: "MLSSWorld", excluded): and StateLogic.canDash(state, world.player) and StateLogic.canCrash(state, world.player) ) - add_rule( - world.get_location(LocationName.BowsersCastleWendyLarryHallwayDigspot), - lambda state: StateLogic.ultra(state, world.player) - and StateLogic.fire(state, world.player) - and StateLogic.canCrash(state, world.player) - ) + if world.options.chuckle_beans != 0: + add_rule( + world.get_location(LocationName.BowsersCastleWendyLarryHallwayDigspot), + lambda state: StateLogic.ultra(state, world.player) + and StateLogic.fire(state, world.player) + and StateLogic.canCrash(state, world.player) + ) add_rule( world.get_location(LocationName.BowsersCastleBeforeFawfulFightBlock1), lambda state: StateLogic.canDig(state, world.player) From f263a0bc912755b40024a45bd971a290afe6cbc4 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 10 Apr 2025 18:18:49 -0700 Subject: [PATCH 30/39] DS3: Mark a lizard location that was previously not annotated (#4860) --- worlds/dark_souls_3/Locations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/dark_souls_3/Locations.py b/worlds/dark_souls_3/Locations.py index cc202c76e8..c5cdbba85d 100644 --- a/worlds/dark_souls_3/Locations.py +++ b/worlds/dark_souls_3/Locations.py @@ -930,7 +930,7 @@ location_tables: Dict[str, List[DS3LocationData]] = { "Great Swamp Ring", miniboss=True), # Giant Crab drop DS3LocationData("RS: Blue Sentinels - Horace", "Blue Sentinels", missable=True, npc=True), # Horace quest - DS3LocationData("RS: Crystal Gem - stronghold, lizard", "Crystal Gem"), + DS3LocationData("RS: Crystal Gem - stronghold, lizard", "Crystal Gem", lizard=True), DS3LocationData("RS: Fading Soul - woods by Crucifixion Woods bonfire", "Fading Soul", static='03,0:53300210::'), From a324c9781541563bde9885c4d7715b89b5e96f63 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Fri, 11 Apr 2025 20:52:20 +0200 Subject: [PATCH 31/39] Factorio: fix FloatRanges writing effectively nil into the mod (#4846) --- worlds/factorio/Options.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index 481ed00987..12fc90c1fd 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -8,17 +8,20 @@ from schema import Schema, Optional, And, Or, SchemaError from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \ StartInventoryPool, PerGameCommonOptions, OptionGroup + # schema helpers class FloatRange: def __init__(self, low, high): self._low = low self._high = high - def validate(self, value): + def validate(self, value) -> float: if not isinstance(value, (float, int)): raise SchemaError(f"should be instance of float or int, but was {value!r}") if not self._low <= value <= self._high: raise SchemaError(f"{value} is not between {self._low} and {self._high}") + return float(value) + LuaBool = Or(bool, And(int, lambda n: n in (0, 1))) From b7b5bf58aa16cae9926f66613db4c7f3d0aa2d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Bolduc?= <16137441+Jouramie@users.noreply.github.com> Date: Fri, 11 Apr 2025 20:19:17 -0400 Subject: [PATCH 32/39] Stardew Valley: Use classvar_matrix to split tests (#4762) * Unroll tests for better parallelization * fix ut test * self review * bro it's the second time today I have to commit some garbage to have a github action rerun because messenger fails what is this * my god can the tests plz pass * code reviews * code reviews * move TestRandomWorlds out of long module --- worlds/stardew_valley/test/TestOptions.py | 89 +++++++------- .../stardew_valley/test/TestRandomWorlds.py | 29 +++++ worlds/stardew_valley/test/__init__.py | 53 ++++---- .../stardew_valley/test/long/TestModsLong.py | 115 ++++++++++-------- .../test/long/TestOptionsLong.py | 48 ++++---- .../test/long/TestPreRolledRandomness.py | 37 +++--- .../test/long/TestRandomWorlds.py | 86 ------------- .../stardew_valley/test/long/option_names.py | 30 ----- worlds/stardew_valley/test/mods/TestMods.py | 110 +++++++---------- .../test/options/option_names.py | 51 ++++++++ .../test/stability/TestUniversalTracker.py | 7 +- 11 files changed, 304 insertions(+), 351 deletions(-) create mode 100644 worlds/stardew_valley/test/TestRandomWorlds.py delete mode 100644 worlds/stardew_valley/test/long/TestRandomWorlds.py delete mode 100644 worlds/stardew_valley/test/long/option_names.py create mode 100644 worlds/stardew_valley/test/options/option_names.py diff --git a/worlds/stardew_valley/test/TestOptions.py b/worlds/stardew_valley/test/TestOptions.py index 9d9af04a4e..4894ea55f2 100644 --- a/worlds/stardew_valley/test/TestOptions.py +++ b/worlds/stardew_valley/test/TestOptions.py @@ -1,12 +1,13 @@ import itertools +from typing import ClassVar from BaseClasses import ItemClassification -from Options import NamedRange +from test.param import classvar_matrix from . import SVTestCase, solo_multiworld, SVTestBase from .assertion import WorldAssertMixin -from .long.option_names import all_option_choices +from .options.option_names import all_option_choices from .options.presets import allsanity_no_mods_6_x_x, allsanity_mods_6_x_x -from .. import items_by_group, Group, StardewValleyWorld +from .. import items_by_group, Group from ..locations import locations_by_tag, LocationTags, location_table from ..options import ExcludeGingerIsland, ToolProgression, Goal, SeasonRandomization, TrapItems, SpecialOrderLocations, ArcadeMachineLocations from ..strings.goal_names import Goal as GoalName @@ -18,42 +19,36 @@ SEASONS = {Season.spring, Season.summer, Season.fall, Season.winter} TOOLS = {"Hoe", "Pickaxe", "Axe", "Watering Can", "Trash Can", "Fishing Rod"} +@classvar_matrix(option_and_choice=all_option_choices) class TestGenerateDynamicOptions(WorldAssertMixin, SVTestCase): - def test_given_special_range_when_generate_then_basic_checks(self): - options = StardewValleyWorld.options_dataclass.type_hints - for option_name, option in options.items(): - if not issubclass(option, NamedRange): - continue - for value in option.special_range_names: - world_options = {option_name: option.special_range_names[value]} - with self.solo_world_sub_test(f"{option_name}: {value}", world_options) as (multiworld, _): - self.assert_basic_checks(multiworld) + option_and_choice: ClassVar[tuple[str, str]] - def test_given_choice_when_generate_then_basic_checks(self): - options = StardewValleyWorld.options_dataclass.type_hints - for option_name, option in options.items(): - if not option.options: - continue - for value in option.options: - world_options = {option_name: option.options[value]} - with self.solo_world_sub_test(f"{option_name}: {value}", world_options) as (multiworld, _): - self.assert_basic_checks(multiworld) + def test_given_option_and_choice_when_generate_then_basic_checks(self): + option, choice = self.option_and_choice + world_options = {option: choice} + with solo_multiworld(world_options) as (multiworld, stardew_world): + self.assert_basic_checks(multiworld) +@classvar_matrix(goal_and_location=[ + ("community_center", GoalName.community_center), + ("grandpa_evaluation", GoalName.grandpa_evaluation), + ("bottom_of_the_mines", GoalName.bottom_of_the_mines), + ("cryptic_note", GoalName.cryptic_note), + ("master_angler", GoalName.master_angler), + ("complete_collection", GoalName.complete_museum), + ("full_house", GoalName.full_house), + ("perfection", GoalName.perfection), +]) class TestGoal(SVTestCase): + goal_and_location: ClassVar[tuple[str, str]] + def test_given_goal_when_generate_then_victory_is_in_correct_location(self): - for goal, location in [("community_center", GoalName.community_center), - ("grandpa_evaluation", GoalName.grandpa_evaluation), - ("bottom_of_the_mines", GoalName.bottom_of_the_mines), - ("cryptic_note", GoalName.cryptic_note), - ("master_angler", GoalName.master_angler), - ("complete_collection", GoalName.complete_museum), - ("full_house", GoalName.full_house), - ("perfection", GoalName.perfection)]: - world_options = {Goal.internal_name: Goal.options[goal]} - with self.solo_world_sub_test(f"Goal: {goal}, Location: {location}", world_options) as (multi_world, _): - victory = multi_world.find_item("Victory", 1) - self.assertEqual(victory.name, location) + goal, location = self.goal_and_location + world_options = {Goal.internal_name: goal} + with solo_multiworld(world_options) as (multi_world, _): + victory = multi_world.find_item("Victory", 1) + self.assertEqual(victory.name, location) class TestSeasonRandomization(SVTestCase): @@ -104,26 +99,28 @@ class TestToolProgression(SVTestBase): self.assertEqual(useful_count, 1) +@classvar_matrix(option_and_choice=all_option_choices) class TestGenerateAllOptionsWithExcludeGingerIsland(WorldAssertMixin, SVTestCase): + option_and_choice: ClassVar[tuple[str, str]] def test_given_choice_when_generate_exclude_ginger_island_then_ginger_island_is_properly_excluded(self): - for option, option_choice in all_option_choices: - if option is ExcludeGingerIsland: - continue + option, option_choice = self.option_and_choice - world_options = { - ExcludeGingerIsland: ExcludeGingerIsland.option_true, - option: option_choice - } + if option == ExcludeGingerIsland.internal_name: + self.skipTest("ExcludeGingerIsland is forced to true") - with self.solo_world_sub_test(f"{option.internal_name}: {option_choice}", world_options) as (multiworld, stardew_world): + world_options = { + ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true, + option: option_choice + } - # Some options, like goals, will force Ginger island back in the game. We want to skip testing those. - if stardew_world.options.exclude_ginger_island != ExcludeGingerIsland.option_true: - continue + with solo_multiworld(world_options) as (multiworld, stardew_world): - self.assert_basic_checks(multiworld) - self.assert_no_ginger_island_content(multiworld) + if stardew_world.options.exclude_ginger_island != ExcludeGingerIsland.option_true: + self.skipTest("Some options, like goals, will force Ginger island back in the game. We want to skip testing those.") + + self.assert_basic_checks(multiworld) + self.assert_no_ginger_island_content(multiworld) class TestTraps(SVTestCase): diff --git a/worlds/stardew_valley/test/TestRandomWorlds.py b/worlds/stardew_valley/test/TestRandomWorlds.py new file mode 100644 index 0000000000..550ae14b55 --- /dev/null +++ b/worlds/stardew_valley/test/TestRandomWorlds.py @@ -0,0 +1,29 @@ +from typing import ClassVar + +from BaseClasses import MultiWorld, get_seed +from test.param import classvar_matrix +from . import SVTestCase, skip_long_tests, solo_multiworld +from .assertion import GoalAssertMixin, OptionAssertMixin, WorldAssertMixin +from .options.option_names import generate_random_world_options + + +@classvar_matrix(n=range(10 if skip_long_tests() else 1000)) +class TestGenerateManyWorlds(GoalAssertMixin, OptionAssertMixin, WorldAssertMixin, SVTestCase): + n: ClassVar[int] + + def test_generate_many_worlds_then_check_results(self): + seed = get_seed() + world_options = generate_random_world_options(seed + self.n) + + print(f"Generating solo multiworld with seed {seed} for Stardew Valley...") + with solo_multiworld(world_options, seed=seed, world_caching=False) as (multiworld, _): + self.assert_multiworld_is_valid(multiworld) + + def assert_multiworld_is_valid(self, multiworld: MultiWorld): + self.assert_victory_exists(multiworld) + self.assert_same_number_items_locations(multiworld) + self.assert_goal_world_is_valid(multiworld) + self.assert_can_reach_island_if_should(multiworld) + self.assert_cropsanity_same_number_items_and_locations(multiworld) + self.assert_festivals_give_access_to_deluxe_scarecrow(multiworld) + self.assert_has_festival_recipes(multiworld) diff --git a/worlds/stardew_valley/test/__init__.py b/worlds/stardew_valley/test/__init__.py index 800b210576..702f590221 100644 --- a/worlds/stardew_valley/test/__init__.py +++ b/worlds/stardew_valley/test/__init__.py @@ -22,21 +22,19 @@ DEFAULT_TEST_SEED = get_seed() logger.info(f"Default Test Seed: {DEFAULT_TEST_SEED}") -class SVTestCase(unittest.TestCase): - # Set False to not skip some 'extra' tests - skip_base_tests: bool = True - # Set False to run tests that take long - skip_long_tests: bool = True +def skip_default_tests() -> bool: + return not bool(os.environ.get("base", False)) - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - base_tests_key = "base" - if base_tests_key in os.environ: - cls.skip_base_tests = not bool(os.environ[base_tests_key]) - long_tests_key = "long" - if long_tests_key in os.environ: - cls.skip_long_tests = not bool(os.environ[long_tests_key]) + +def skip_long_tests() -> bool: + return not bool(os.environ.get("long", False)) + + +class SVTestCase(unittest.TestCase): + skip_default_tests: bool = skip_default_tests() + """Set False to not skip the base fill tests""" + skip_long_tests: bool = skip_long_tests() + """Set False to run tests that take long""" @contextmanager def solo_world_sub_test(self, msg: Optional[str] = None, @@ -94,7 +92,7 @@ class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase): @property def run_default_tests(self) -> bool: - if self.skip_base_tests: + if self.skip_default_tests: return False return super().run_default_tests @@ -196,21 +194,22 @@ def solo_multiworld(world_options: Optional[Dict[Union[str, StardewValleyOption] yield multiworld, multiworld.worlds[1] else: multiworld = setup_solo_multiworld(world_options, seed) - multiworld.lock.acquire() - world = multiworld.worlds[1] + try: + multiworld.lock.acquire() + world = multiworld.worlds[1] - original_state = multiworld.state.copy() - original_itempool = multiworld.itempool.copy() - unfilled_locations = multiworld.get_unfilled_locations(1) + original_state = multiworld.state.copy() + original_itempool = multiworld.itempool.copy() + unfilled_locations = multiworld.get_unfilled_locations(1) - yield multiworld, world + yield multiworld, world - multiworld.state = original_state - multiworld.itempool = original_itempool - for location in unfilled_locations: - location.item = None - - multiworld.lock.release() + multiworld.state = original_state + multiworld.itempool = original_itempool + for location in unfilled_locations: + location.item = None + finally: + multiworld.lock.release() # Mostly a copy of test.general.setup_solo_multiworld, I just don't want to change the core. diff --git a/worlds/stardew_valley/test/long/TestModsLong.py b/worlds/stardew_valley/test/long/TestModsLong.py index 395c48ee69..bc5e8bfff8 100644 --- a/worlds/stardew_valley/test/long/TestModsLong.py +++ b/worlds/stardew_valley/test/long/TestModsLong.py @@ -1,61 +1,19 @@ import unittest -from itertools import combinations, product +from itertools import combinations +from typing import ClassVar from BaseClasses import get_seed -from .option_names import all_option_choices, get_option_choices -from .. import SVTestCase +from test.param import classvar_matrix +from .. import SVTestCase, solo_multiworld, skip_long_tests from ..assertion import WorldAssertMixin, ModAssertMixin +from ..options.option_names import all_option_choices from ... import options from ...mods.mod_data import ModNames - -assert unittest +from ...options.options import all_mods -class TestGenerateModsOptions(WorldAssertMixin, ModAssertMixin, SVTestCase): - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - if cls.skip_long_tests: - raise unittest.SkipTest("Long tests disabled") - - def test_given_mod_pairs_when_generate_then_basic_checks(self): - for mod_pair in combinations(options.Mods.valid_keys, 2): - world_options = { - options.Mods: frozenset(mod_pair) - } - - with self.solo_world_sub_test(f"Mods: {mod_pair}", world_options, world_caching=False) as (multiworld, _): - self.assert_basic_checks(multiworld) - self.assert_stray_mod_items(list(mod_pair), multiworld) - - def test_given_mod_names_when_generate_paired_with_other_options_then_basic_checks(self): - for mod, (option, value) in product(options.Mods.valid_keys, all_option_choices): - world_options = { - option: value, - options.Mods: mod - } - - with self.solo_world_sub_test(f"{option.internal_name}: {value}, Mod: {mod}", world_options, world_caching=False) as (multiworld, _): - self.assert_basic_checks(multiworld) - self.assert_stray_mod_items(mod, multiworld) - - def test_given_no_quest_all_mods_when_generate_with_all_goals_then_basic_checks(self): - for goal, (option, value) in product(get_option_choices(options.Goal), all_option_choices): - if option is options.QuestLocations: - continue - - world_options = { - options.Goal: goal, - option: value, - options.QuestLocations: -1, - options.Mods: frozenset(options.Mods.valid_keys), - } - - with self.solo_world_sub_test(f"Goal: {goal}, {option.internal_name}: {value}", world_options, world_caching=False) as (multiworld, _): - self.assert_basic_checks(multiworld) - - @unittest.skip +@unittest.skip +class TestTroubleshootMods(WorldAssertMixin, ModAssertMixin, SVTestCase): def test_troubleshoot_option(self): seed = get_seed(78709133382876990000) @@ -67,3 +25,60 @@ class TestGenerateModsOptions(WorldAssertMixin, ModAssertMixin, SVTestCase): with self.solo_world_sub_test(world_options=world_options, seed=seed, world_caching=False) as (multiworld, _): self.assert_basic_checks(multiworld) self.assert_stray_mod_items(world_options[options.Mods], multiworld) + + +if skip_long_tests(): + raise unittest.SkipTest("Long tests disabled") + + +@classvar_matrix(mod_pair=combinations(sorted(all_mods), 2)) +class TestGenerateModsPairs(WorldAssertMixin, ModAssertMixin, SVTestCase): + mod_pair: ClassVar[tuple[str, str]] + + def test_given_mod_pairs_when_generate_then_basic_checks(self): + world_options = { + options.Mods.internal_name: frozenset(self.mod_pair) + } + + with solo_multiworld(world_options, world_caching=False) as (multiworld, _): + self.assert_basic_checks(multiworld) + self.assert_stray_mod_items(list(self.mod_pair), multiworld) + + +@classvar_matrix(mod=all_mods, option_and_choice=all_option_choices) +class TestGenerateModAndOptionChoice(WorldAssertMixin, ModAssertMixin, SVTestCase): + mod: ClassVar[str] + option_and_choice: ClassVar[tuple[str, str]] + + def test_given_mod_names_when_generate_paired_with_other_options_then_basic_checks(self): + option, choice = self.option_and_choice + + world_options = { + option: choice, + options.Mods.internal_name: self.mod + } + + with solo_multiworld(world_options, world_caching=False) as (multiworld, _): + self.assert_basic_checks(multiworld) + self.assert_stray_mod_items(self.mod, multiworld) + + +@classvar_matrix(goal=options.Goal.options.keys(), option_and_choice=all_option_choices) +class TestGenerateAllGoalAndAllOptionWithAllModsWithoutQuest(WorldAssertMixin, ModAssertMixin, SVTestCase): + goal = ClassVar[str] + option_and_choice = ClassVar[tuple[str, str]] + + def test_given_no_quest_all_mods_when_generate_with_all_goals_then_basic_checks(self): + option, choice = self.option_and_choice + if option == options.QuestLocations.internal_name: + self.skipTest("QuestLocations are disabled") + + world_options = { + options.Goal.internal_name: self.goal, + option: choice, + options.QuestLocations.internal_name: -1, + options.Mods.internal_name: frozenset(options.Mods.valid_keys), + } + + with solo_multiworld(world_options, world_caching=False) as (multiworld, _): + self.assert_basic_checks(multiworld) diff --git a/worlds/stardew_valley/test/long/TestOptionsLong.py b/worlds/stardew_valley/test/long/TestOptionsLong.py index 81bb4d1f30..db467964e7 100644 --- a/worlds/stardew_valley/test/long/TestOptionsLong.py +++ b/worlds/stardew_valley/test/long/TestOptionsLong.py @@ -1,34 +1,16 @@ import unittest from itertools import combinations +from typing import ClassVar from BaseClasses import get_seed -from .option_names import all_option_choices -from .. import SVTestCase, solo_multiworld +from test.param import classvar_matrix +from .. import SVTestCase, solo_multiworld, skip_long_tests from ..assertion.world_assert import WorldAssertMixin +from ..options.option_names import all_option_choices from ... import options -class TestGenerateDynamicOptions(WorldAssertMixin, SVTestCase): - def test_given_option_pair_when_generate_then_basic_checks(self): - if self.skip_long_tests: - raise unittest.SkipTest("Long tests disabled") - - for (option1, option1_choice), (option2, option2_choice) in combinations(all_option_choices, 2): - if option1 is option2: - continue - - world_options = { - option1: option1_choice, - option2: option2_choice - } - - with self.solo_world_sub_test(f"{option1.internal_name}: {option1_choice}, {option2.internal_name}: {option2_choice}", - world_options, - world_caching=False) \ - as (multiworld, _): - self.assert_basic_checks(multiworld) - - +@unittest.skip class TestDynamicOptionDebug(WorldAssertMixin, SVTestCase): def test_option_pair_debug(self): @@ -42,3 +24,23 @@ class TestDynamicOptionDebug(WorldAssertMixin, SVTestCase): print(f"Seed: {seed}") with solo_multiworld(option_dict, seed=seed) as (multiworld, _): self.assert_basic_checks(multiworld) + + +if skip_long_tests(): + raise unittest.SkipTest("Long tests disabled") + + +@classvar_matrix(options_and_choices=combinations(all_option_choices, 2)) +class TestGenerateDynamicOptions(WorldAssertMixin, SVTestCase): + options_and_choices: ClassVar[tuple[tuple[str, str], tuple[str, str]]] + + def test_given_option_pair_when_generate_then_basic_checks(self): + (option1, option1_choice), (option2, option2_choice) = self.options_and_choices + + world_options = { + option1: option1_choice, + option2: option2_choice + } + + with solo_multiworld(world_options, world_caching=False) as (multiworld, _): + self.assert_basic_checks(multiworld) diff --git a/worlds/stardew_valley/test/long/TestPreRolledRandomness.py b/worlds/stardew_valley/test/long/TestPreRolledRandomness.py index f233fc36dc..3b6f818ec4 100644 --- a/worlds/stardew_valley/test/long/TestPreRolledRandomness.py +++ b/worlds/stardew_valley/test/long/TestPreRolledRandomness.py @@ -1,28 +1,29 @@ import unittest +from typing import ClassVar from BaseClasses import get_seed -from .. import SVTestCase +from test.param import classvar_matrix +from .. import SVTestCase, solo_multiworld, skip_long_tests from ..assertion import WorldAssertMixin from ... import options +if skip_long_tests(): + raise unittest.SkipTest("Long tests disabled") +player_options = { + options.EntranceRandomization.internal_name: options.EntranceRandomization.option_buildings, + options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, + options.BundlePrice.internal_name: options.BundlePrice.option_maximum +} + + +@classvar_matrix(n=range(1000)) class TestGeneratePreRolledRandomness(WorldAssertMixin, SVTestCase): + n: ClassVar[int] + def test_given_pre_rolled_difficult_randomness_when_generate_then_basic_checks(self): - if self.skip_long_tests: - raise unittest.SkipTest("Long tests disabled") + seed = get_seed() - choices = { - options.EntranceRandomization.internal_name: options.EntranceRandomization.option_buildings, - options.BundleRandomization.internal_name: options.BundleRandomization.option_remixed, - options.BundlePrice.internal_name: options.BundlePrice.option_maximum - } - - num_tests = 1000 - for i in range(num_tests): - seed = get_seed() # Put seed in parameter to test - with self.solo_world_sub_test(f"Entrance Randomizer and Remixed Bundles", - choices, - seed=seed, - world_caching=False) \ - as (multiworld, _): - self.assert_basic_checks(multiworld) + print(f"Generating solo multiworld with seed {seed} for Stardew Valley...") + with solo_multiworld(player_options, seed=seed, world_caching=False) as (multiworld, _): + self.assert_basic_checks(multiworld) diff --git a/worlds/stardew_valley/test/long/TestRandomWorlds.py b/worlds/stardew_valley/test/long/TestRandomWorlds.py deleted file mode 100644 index 6d4931280a..0000000000 --- a/worlds/stardew_valley/test/long/TestRandomWorlds.py +++ /dev/null @@ -1,86 +0,0 @@ -import random -import unittest -from typing import Dict - -from BaseClasses import MultiWorld, get_seed -from Options import NamedRange, Range -from .option_names import options_to_include -from .. import SVTestCase -from ..assertion import GoalAssertMixin, OptionAssertMixin, WorldAssertMixin - - -def get_option_choices(option) -> Dict[str, int]: - if issubclass(option, NamedRange): - return option.special_range_names - if issubclass(option, Range): - return {f"{val}": val for val in range(option.range_start, option.range_end + 1)} - elif option.options: - return option.options - return {} - - -def generate_random_world_options(seed: int) -> Dict[str, int]: - num_options = len(options_to_include) - world_options = dict() - rng = random.Random(seed) - for option_index in range(0, num_options): - option = options_to_include[option_index] - option_choices = get_option_choices(option) - if not option_choices: - continue - chosen_option_value = rng.choice(list(option_choices.values())) - world_options[option.internal_name] = chosen_option_value - return world_options - - -def get_number_log_steps(number_worlds: int) -> int: - if number_worlds <= 10: - return 2 - if number_worlds <= 100: - return 5 - if number_worlds <= 500: - return 10 - if number_worlds <= 1000: - return 20 - if number_worlds <= 5000: - return 25 - if number_worlds <= 10000: - return 50 - return 100 - - -class TestGenerateManyWorlds(GoalAssertMixin, OptionAssertMixin, WorldAssertMixin, SVTestCase): - def test_generate_many_worlds_then_check_results(self): - if self.skip_long_tests: - raise unittest.SkipTest("Long tests disabled") - - number_worlds = 10 if self.skip_long_tests else 1000 - seed = get_seed() - self.generate_and_check_many_worlds(number_worlds, seed) - - def generate_and_check_many_worlds(self, number_worlds: int, seed: int): - num_steps = get_number_log_steps(number_worlds) - log_step = number_worlds / num_steps - - print(f"Generating {number_worlds} Solo Multiworlds [Start Seed: {seed}] for Stardew Valley...") - for world_number in range(0, number_worlds + 1): - - world_seed = world_number + seed - world_options = generate_random_world_options(world_seed) - - with self.solo_world_sub_test(f"Multiworld: {world_seed}", world_options, seed=world_seed, world_caching=False) as (multiworld, _): - self.assert_multiworld_is_valid(multiworld) - - if world_number > 0 and world_number % log_step == 0: - print(f"Generated and Verified {world_number}/{number_worlds} worlds [{(world_number * 100) // number_worlds}%]") - - print(f"Finished generating and verifying {number_worlds} Solo Multiworlds for Stardew Valley") - - def assert_multiworld_is_valid(self, multiworld: MultiWorld): - self.assert_victory_exists(multiworld) - self.assert_same_number_items_locations(multiworld) - self.assert_goal_world_is_valid(multiworld) - self.assert_can_reach_island_if_should(multiworld) - self.assert_cropsanity_same_number_items_and_locations(multiworld) - self.assert_festivals_give_access_to_deluxe_scarecrow(multiworld) - self.assert_has_festival_recipes(multiworld) diff --git a/worlds/stardew_valley/test/long/option_names.py b/worlds/stardew_valley/test/long/option_names.py deleted file mode 100644 index 9f3cf98b87..0000000000 --- a/worlds/stardew_valley/test/long/option_names.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Dict - -from Options import NamedRange -from ... import StardewValleyWorld - -options_to_exclude = {"profit_margin", "starting_money", "multiple_day_sleep_enabled", "multiple_day_sleep_cost", - "experience_multiplier", "friendship_multiplier", "debris_multiplier", - "quick_start", "gifting", "gift_tax", - "progression_balancing", "accessibility", "start_inventory", "start_hints", "death_link"} - -options_to_include = [option - for option_name, option in StardewValleyWorld.options_dataclass.type_hints.items() - if option_name not in options_to_exclude] - - -def get_option_choices(option) -> Dict[str, int]: - if issubclass(option, NamedRange): - return option.special_range_names - elif option.options: - return option.options - return {} - - -all_option_choices = [(option, value) - for option in options_to_include - if option.options - for value in get_option_choices(option) - if option.default != get_option_choices(option)[value]] - -assert all_option_choices diff --git a/worlds/stardew_valley/test/mods/TestMods.py b/worlds/stardew_valley/test/mods/TestMods.py index 932c76c680..bd5d7d626d 100644 --- a/worlds/stardew_valley/test/mods/TestMods.py +++ b/worlds/stardew_valley/test/mods/TestMods.py @@ -1,88 +1,64 @@ import random +from typing import ClassVar from BaseClasses import get_seed -from .. import SVTestBase, SVTestCase +from test.param import classvar_matrix +from .. import SVTestBase, SVTestCase, solo_multiworld from ..TestGeneration import get_all_permanent_progression_items from ..assertion import ModAssertMixin, WorldAssertMixin from ..options.presets import allsanity_mods_6_x_x from ..options.utils import fill_dataclass_with_default from ... import options, Group, create_content from ...mods.mod_data import ModNames -from ...options import SkillProgression, Walnutsanity from ...options.options import all_mods from ...regions import RandomizationFlag, randomize_connections, create_final_connections_and_regions -class TestGenerateModsOptions(WorldAssertMixin, ModAssertMixin, SVTestCase): - - def test_given_single_mods_when_generate_then_basic_checks(self): - for mod in options.Mods.valid_keys: - world_options = {options.Mods: mod, options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false} - with self.solo_world_sub_test(f"Mod: {mod}", world_options) as (multi_world, _): - self.assert_basic_checks(multi_world) - self.assert_stray_mod_items(mod, multi_world) - - # The following tests validate that ER still generates winnable and logically-sane games with given mods. - # Mods that do not interact with entrances are skipped - # Not all ER settings are tested, because 'buildings' is, essentially, a superset of all others - def test_deepwoods_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.deepwoods, options.EntranceRandomization.option_buildings) - - def test_juna_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.juna, options.EntranceRandomization.option_buildings) - - def test_jasper_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.jasper, options.EntranceRandomization.option_buildings) - - def test_alec_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.alec, options.EntranceRandomization.option_buildings) - - def test_yoba_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.yoba, options.EntranceRandomization.option_buildings) - - def test_eugene_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.eugene, options.EntranceRandomization.option_buildings) - - def test_ayeisha_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.ayeisha, options.EntranceRandomization.option_buildings) - - def test_riley_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.riley, options.EntranceRandomization.option_buildings) - - def test_sve_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.sve, options.EntranceRandomization.option_buildings) - - def test_alecto_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.alecto, options.EntranceRandomization.option_buildings) - - def test_lacey_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.lacey, options.EntranceRandomization.option_buildings) - - def test_boarding_house_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(ModNames.boarding_house, options.EntranceRandomization.option_buildings) - - def test_all_mods_entrance_randomization_buildings(self): - self.perform_basic_checks_on_mod_with_er(all_mods, options.EntranceRandomization.option_buildings) - - def perform_basic_checks_on_mod_with_er(self, mods: str | set[str], er_option: int) -> None: - if isinstance(mods, str): - mods = {mods} - world_options = { - options.EntranceRandomization: er_option, - options.Mods: frozenset(mods), - options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false - } - with self.solo_world_sub_test(f"entrance_randomization: {er_option}, Mods: {mods}", world_options) as (multi_world, _): - self.assert_basic_checks(multi_world) +class TestCanGenerateAllsanityWithMods(WorldAssertMixin, ModAssertMixin, SVTestCase): def test_allsanity_all_mods_when_generate_then_basic_checks(self): - with self.solo_world_sub_test(world_options=allsanity_mods_6_x_x()) as (multi_world, _): + with solo_multiworld(allsanity_mods_6_x_x()) as (multi_world, _): self.assert_basic_checks(multi_world) def test_allsanity_all_mods_exclude_island_when_generate_then_basic_checks(self): world_options = allsanity_mods_6_x_x() world_options.update({options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_true}) - with self.solo_world_sub_test(world_options=world_options) as (multi_world, _): + with solo_multiworld(world_options) as (multi_world, _): + self.assert_basic_checks(multi_world) + + +@classvar_matrix(mod=all_mods) +class TestCanGenerateWithEachMod(WorldAssertMixin, ModAssertMixin, SVTestCase): + mod: ClassVar[str] + + def test_given_single_mods_when_generate_then_basic_checks(self): + world_options = { + options.Mods: self.mod, + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false + } + with solo_multiworld(world_options) as (multi_world, _): + self.assert_basic_checks(multi_world) + self.assert_stray_mod_items(self.mod, multi_world) + + +@classvar_matrix(mod=all_mods.difference([ + ModNames.ginger, ModNames.distant_lands, ModNames.skull_cavern_elevator, ModNames.wellwick, ModNames.magic, ModNames.binning_skill, ModNames.big_backpack, + ModNames.luck_skill, ModNames.tractor, ModNames.shiko, ModNames.archaeology, ModNames.delores, ModNames.socializing_skill, ModNames.cooking_skill +])) +class TestCanGenerateEachModWithEntranceRandomizationBuildings(WorldAssertMixin, SVTestCase): + """The following tests validate that ER still generates winnable and logically-sane games with given mods. + Mods that do not interact with entrances are skipped + Not all ER settings are tested, because 'buildings' is, essentially, a superset of all others + """ + mod: ClassVar[str] + + def test_given_mod_when_generate_then_basic_checks(self) -> None: + world_options = { + options.EntranceRandomization: options.EntranceRandomization.option_buildings, + options.Mods: self.mod, + options.ExcludeGingerIsland: options.ExcludeGingerIsland.option_false + } + with solo_multiworld(world_options, world_caching=False) as (multi_world, _): self.assert_basic_checks(multi_world) @@ -105,7 +81,7 @@ class TestBaseItemGeneration(SVTestBase): options.Chefsanity.internal_name: options.Chefsanity.option_all, options.Craftsanity.internal_name: options.Craftsanity.option_all, options.Booksanity.internal_name: options.Booksanity.option_all, - Walnutsanity.internal_name: Walnutsanity.preset_all, + options.Walnutsanity.internal_name: options.Walnutsanity.preset_all, options.Mods.internal_name: frozenset(options.Mods.valid_keys) } @@ -151,7 +127,7 @@ class TestModEntranceRando(SVTestCase): sv_options = fill_dataclass_with_default({ options.EntranceRandomization.internal_name: option, options.ExcludeGingerIsland.internal_name: options.ExcludeGingerIsland.option_false, - SkillProgression.internal_name: SkillProgression.option_progressive_with_masteries, + options.SkillProgression.internal_name: options.SkillProgression.option_progressive_with_masteries, options.Mods.internal_name: frozenset(options.Mods.valid_keys) }) content = create_content(sv_options) diff --git a/worlds/stardew_valley/test/options/option_names.py b/worlds/stardew_valley/test/options/option_names.py new file mode 100644 index 0000000000..07fa42b508 --- /dev/null +++ b/worlds/stardew_valley/test/options/option_names.py @@ -0,0 +1,51 @@ +import random + +from Options import NamedRange, Option, Range +from ... import StardewValleyWorld +from ...options import StardewValleyOption + +options_to_exclude = {"profit_margin", "starting_money", "multiple_day_sleep_enabled", "multiple_day_sleep_cost", + "experience_multiplier", "friendship_multiplier", "debris_multiplier", + "quick_start", "gifting", "gift_tax", + "progression_balancing", "accessibility", "start_inventory", "start_hints", "death_link"} + +options_to_include: list[type[StardewValleyOption | Option]] = [ + option + for option_name, option in StardewValleyWorld.options_dataclass.type_hints.items() + if option_name not in options_to_exclude +] + + +def get_option_choices(option: type[Option]) -> dict[str, int]: + if issubclass(option, NamedRange): + return option.special_range_names + if issubclass(option, Range): + return {f"{val}": val for val in range(option.range_start, option.range_end + 1)} + elif option.options: + return option.options + return {} + + +def generate_random_world_options(seed: int) -> dict[str, int]: + num_options = len(options_to_include) + world_options = dict() + rng = random.Random(seed) + for option_index in range(0, num_options): + option = options_to_include[option_index] + option_choices = get_option_choices(option) + if not option_choices: + continue + chosen_option_value = rng.choice(list(option_choices.values())) + world_options[option.internal_name] = chosen_option_value + return world_options + + +all_option_choices = [ + (option.internal_name, value) + for option in options_to_include + if option.options + for value in get_option_choices(option) + if option.default != get_option_choices(option)[value] +] + +assert all_option_choices diff --git a/worlds/stardew_valley/test/stability/TestUniversalTracker.py b/worlds/stardew_valley/test/stability/TestUniversalTracker.py index 5e8075e4a1..0268d9e515 100644 --- a/worlds/stardew_valley/test/stability/TestUniversalTracker.py +++ b/worlds/stardew_valley/test/stability/TestUniversalTracker.py @@ -1,11 +1,12 @@ import unittest from unittest.mock import Mock -from .. import SVTestBase, fill_namespace_with_default +from .. import SVTestBase, fill_namespace_with_default, skip_long_tests from ..options.presets import allsanity_mods_6_x_x from ... import STARDEW_VALLEY, FarmType, BundleRandomization, EntranceRandomization +@unittest.skipIf(skip_long_tests(), "Long tests disabled") class TestUniversalTrackerGenerationIsStable(SVTestBase): options = allsanity_mods_6_x_x() options.update({ @@ -16,8 +17,6 @@ class TestUniversalTrackerGenerationIsStable(SVTestBase): def test_all_locations_and_items_are_the_same_between_two_generations(self): # This might open a kivy window temporarily, but it's the only way to test this... - if self.skip_long_tests: - raise unittest.SkipTest("Long tests disabled") try: # This test only run if UT is present, so no risk of running in the CI. @@ -30,7 +29,7 @@ class TestUniversalTrackerGenerationIsStable(SVTestBase): fake_context = Mock() fake_context.re_gen_passthrough = {STARDEW_VALLEY: ut_data} - args = fill_namespace_with_default({0: self.options}) + args = fill_namespace_with_default([self.options]) args.outputpath = None args.outputname = None args.multi = 1 From 347efac0cd787ded2eb79299e5eee6e60789733e Mon Sep 17 00:00:00 2001 From: agilbert1412 Date: Fri, 11 Apr 2025 20:41:08 -0400 Subject: [PATCH 33/39] DLC Quest - Skip two long tests in the main pipeline (#4862) * - Set up the two long tests to only run when the specific config is active * Apply Black Sliver's suggestion --- worlds/dlcquest/test/TestOptionsLong.py | 9 +++++++-- worlds/dlcquest/test/__init__.py | 15 +++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/worlds/dlcquest/test/TestOptionsLong.py b/worlds/dlcquest/test/TestOptionsLong.py index c6c594b6a0..d31e82c00f 100644 --- a/worlds/dlcquest/test/TestOptionsLong.py +++ b/worlds/dlcquest/test/TestOptionsLong.py @@ -1,10 +1,11 @@ +import unittest from typing import Dict from BaseClasses import MultiWorld from Options import NamedRange -from .option_names import options_to_include -from .checks.world_checks import assert_can_win, assert_same_number_items_locations from . import DLCQuestTestBase, setup_dlc_quest_solo_multiworld +from .checks.world_checks import assert_can_win, assert_same_number_items_locations +from .option_names import options_to_include def basic_checks(tester: DLCQuestTestBase, multiworld: MultiWorld): @@ -38,6 +39,8 @@ class TestGenerateDynamicOptions(DLCQuestTestBase): basic_checks(self, multiworld) def test_given_option_truple_when_generate_then_basic_checks(self): + if self.skip_long_tests: + raise unittest.SkipTest("Long tests disabled") num_options = len(options_to_include) for option1_index in range(0, num_options): for option2_index in range(option1_index + 1, num_options): @@ -59,6 +62,8 @@ class TestGenerateDynamicOptions(DLCQuestTestBase): basic_checks(self, multiworld) def test_given_option_quartet_when_generate_then_basic_checks(self): + if self.skip_long_tests: + raise unittest.SkipTest("Long tests disabled") num_options = len(options_to_include) for option1_index in range(0, num_options): for option2_index in range(option1_index + 1, num_options): diff --git a/worlds/dlcquest/test/__init__.py b/worlds/dlcquest/test/__init__.py index 0432ae8b60..bcc4c14659 100644 --- a/worlds/dlcquest/test/__init__.py +++ b/worlds/dlcquest/test/__init__.py @@ -1,19 +1,26 @@ -from typing import ClassVar - -from typing import Dict, FrozenSet, Tuple, Any +import os from argparse import Namespace +from typing import ClassVar +from typing import Dict, FrozenSet, Tuple, Any from BaseClasses import MultiWorld from test.bases import WorldTestBase -from .. import DLCqworld from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld from worlds.AutoWorld import call_all +from .. import DLCqworld class DLCQuestTestBase(WorldTestBase): game = "DLCQuest" world: DLCqworld player: ClassVar[int] = 1 + # Set False to run tests that take long + skip_long_tests: bool = True + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.skip_long_tests = not bool(os.environ.get("long")) def world_setup(self, *args, **kwargs): super().world_setup(*args, **kwargs) From ec1e113b4c6dbaa977a46325fb6cb7d958ea531e Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sun, 13 Apr 2025 13:10:36 +0200 Subject: [PATCH 34/39] Doc: fix parse_yaml in adding games.md (#4872) --- docs/adding games.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adding games.md b/docs/adding games.md index 2decb667b7..c3eb0d023e 100644 --- a/docs/adding games.md +++ b/docs/adding games.md @@ -146,8 +146,8 @@ workarounds or preferred methods which should be used instead: * If you need to place specific items, there are multiple ways to do so, but they should not be added to the multiworld itempool. * It is not allowed to use `eval` for most reasons, chiefly due to security concerns. -* It is discouraged to use `yaml.load` directly due to security concerns. - * When possible, use `Utils.yaml_load` instead, as this defaults to the safe loader. +* It is discouraged to use PyYAML (i.e. `yaml.load`) directly due to security concerns. + * When possible, use `Utils.parse_yaml` instead, as this defaults to the safe loader and the faster C parser. * When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively), do **not** use `=` as this will overwrite all elements for all games in the seed. * Instead, use `append`, `extend`, or `+=`. From 1873c52aa6f66fb79657555098d6c8e2efc9dada Mon Sep 17 00:00:00 2001 From: Seldom <38388947+Seldom-SE@users.noreply.github.com> Date: Tue, 15 Apr 2025 06:51:05 -0700 Subject: [PATCH 35/39] Terraria: 1.4.4 and Calamity support (#3847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Terraria integration * Precollected items for debugging * Fix item classification * Golem requires Plantera's Bulb * Pumpkin Moon requires Dungeon * Progressive Dungeon * Reorg, Options.py work * Items are boss flags * Removed unused option * Removed nothing * Wall, Plantera, and Zenith goals * Achievements and items * Fixed The Cavalry and Completely Awesome achievements * Made "Dead Men Tell No Tales" a grindy achievement * Some docs, Python 3.8 compat * docs * Fix extra item and "Head in the Clouds" being included when achievements are disabled * Requested changes * Fix potential thread unsafety, replace Nothing with 50 Silver * Remove a log * Corrected heading * Added incompatible mods list * In-progress calamity integration * Terraria events progress * Rules use events * Removed an intentional crash I accidentally left in * Fixed infinite loop * Moved rules to data file * Moved item rewards to data file * Generating from data file * Fixed broken Mech Boss goal * Changes Calamity makes to vanilla rules, Calamity final bosses goal * Added Deerclops, fixed Zenith goal * Final detailed vanilla pass * Disable calamity goals * Typo * Fixed some reward items not adding to item pool * In-progress unit test fixes * Unit test fixes * `.apworld` compat * Organized rewards file, made Frog Leg and Fllpper available in vanilla * Water Walking Boots and Titan Glove rewards * Add goals to slot data * Fixed Hammush logic in Post-Mech goal * Fixed coin rewards * Updated Terraria docs * Formatted * Deathlink in-progress * Boots of the Hero is grindy * Fixed zenith goal not placing an item * Address review * Gelatin World Tour is grindy * Difficulty notice * Switched some achievements' grindiness * Added "Hey! Listen!" achievement * Terarria Python 3.8 compat * Fixed Terraria You and What Army logic * Calamity minion accessories * Typo * Calamity integration * `deathlink` -> `death_link` Co-authored-by: Zach Parks * Missing `.` Co-authored-by: Zach Parks * Incorrect type annotation Co-authored-by: Zach Parks * `deathlink` -> `death_link` 2 Co-authored-by: Zach Parks * Style Co-authored-by: Zach Parks * Markdown style Co-authored-by: Zach Parks * Markdown style 2 Co-authored-by: Zach Parks * Address review * Fix bad merge * Terraria utility mod recommendations * Calamity minion armor logic * ArmorMinions -> Armor Minions, boss rush goal, fixed unplaced item * Fixed unplaced item * Started on Terraria 1.4.4 * Crate logic * getfixedboi, 1.4.4 achievements, shimmer, town slimes, `Rule`, `Condition`, etc * More clam getfixedboi logic, bar decraft logic, `NotGetfixedboi` -> `Not Getfixedboi` * Calamity fixes * Calamity crate ore logic * Fixed item accessibility not generating in getfixedboi, fixed not generating with incompatible options, fixed grindy function * Early achievements, separate achievement category options * Infinity +1 Sword achievement can be location in later goals * The Frequent Flyer is impossible in Calamity getfixedboi * Add Enchanted Sword and Starfury for starting inventories * Don't Dread on Me is redundant in Calamity * In Calamity getfixedboi, Queen Bee summons enemies who drop Plague Cell Canisters * Can't use Gelatin Crystal outside Hallow * You can't get the Terminus without flags * Typo * Options difficult warnings * Robbing the Grave is Hardmode * Don't reserve an ID for unused Victory item * Plantera is accessible early in Calamity via Giant Plantera's Bulbs * Unshuffled Life Crystal and Defender Medal items * Comment about Midas' Blessing * Update worlds/terraria/Options.py Co-authored-by: Scipio Wright * Remove stray expression Co-authored-by: Scipio Wright * Review suggestions * Option naming caps consistency, add Laser Drill, Lunatic Cultist alt reqs, fix Eldritch Soul Artifact, Ceaseless Void reqs Dungeon * Cal Clone doesn't drop Broken Hero Sword anymore, Laser Drill is weaker in Calamity Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com> * Fix Acid Rain logic * Fix XB-∞ Hekate failing accessibility checks (by commenting it out bc it doesn't affect logic) * Hardmode ores being fishable early in Calamity is not a bug anymore * Mecha Mayhem is inaccessible in getfixedboi * Update worlds/terraria/Rules.dsv Co-authored-by: Seafo <92278897+Seatori@users.noreply.github.com> --------- Co-authored-by: Fabian Dill Co-authored-by: Zach Parks Co-authored-by: Scipio Wright Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com> --- worlds/terraria/Checks.py | 245 +++++++++++-------- worlds/terraria/Options.py | 73 ++++-- worlds/terraria/Rewards.dsv | 12 +- worlds/terraria/Rules.dsv | 465 ++++++++++++++++++++++++------------ worlds/terraria/__init__.py | 247 ++++++++++--------- 5 files changed, 661 insertions(+), 381 deletions(-) diff --git a/worlds/terraria/Checks.py b/worlds/terraria/Checks.py index 0630d6290b..53e6626204 100644 --- a/worlds/terraria/Checks.py +++ b/worlds/terraria/Checks.py @@ -157,24 +157,57 @@ COND_FN = 2 COND_GROUP = 3 +class Condition: + def __init__( + self, + # True = positive, False = negative + sign: bool, + # See the `COND_*` constants + type: int, + # Condition name or list + condition: Union[str, Tuple[Union[bool, None], List["Condition"]]], + argument: Union[str, int, None], + ): + self.sign = sign + self.type = type + self.condition = condition + self.argument = argument + + +class Rule: + def __init__( + self, + name: str, + # Name to arg + flags: Dict[str, Union[str, int, None]], + # True = or, False = and, None = N/A + operator: Union[bool, None], + conditions: List[Condition], + ): + self.name = name + self.flags = flags + self.operator = operator + self.conditions = conditions + + def validate_conditions( rule: str, rule_indices: dict, - conditions: List[ - Tuple[ - bool, int, Union[str, Tuple[Union[bool, None], list]], Union[str, int, None] - ] - ], + conditions: List[Condition], ): - for _, type, condition, _ in conditions: - if type == COND_ITEM: - if condition not in rule_indices: - raise Exception(f"item `{condition}` in `{rule}` is not defined") - elif type == COND_LOC: - if condition not in rule_indices: - raise Exception(f"location `{condition}` in `{rule}` is not defined") - elif type == COND_FN: - if condition not in { + for condition in conditions: + if condition.type == COND_ITEM: + if condition.condition not in rule_indices: + raise Exception( + f"item `{condition.condition}` in `{rule}` is not defined" + ) + elif condition.type == COND_LOC: + if condition.condition not in rule_indices: + raise Exception( + f"location `{condition.condition}` in `{rule}` is not defined" + ) + elif condition.type == COND_FN: + if condition.condition not in { "npc", "calamity", "grindy", @@ -182,43 +215,48 @@ def validate_conditions( "hammer", "mech_boss", "minions", + "getfixedboi", }: - raise Exception(f"function `{condition}` in `{rule}` is not defined") - elif type == COND_GROUP: - _, conditions = condition + raise Exception( + f"function `{condition.condition}` in `{rule}` is not defined" + ) + elif condition.type == COND_GROUP: + _, conditions = condition.condition validate_conditions(rule, rule_indices, conditions) def mark_progression( - conditions: List[ - Tuple[ - bool, int, Union[str, Tuple[Union[bool, None], list]], Union[str, int, None] - ] - ], + conditions: List[Condition], progression: Set[str], rules: list, rule_indices: dict, loc_to_item: dict, ): - for _, type, condition, _ in conditions: - if type == COND_ITEM: - prog = condition in progression - progression.add(loc_to_item[condition]) - _, flags, _, conditions = rules[rule_indices[condition]] + for condition in conditions: + if condition.type == COND_ITEM: + prog = condition.condition in progression + progression.add(loc_to_item[condition.condition]) + rule = rules[rule_indices[condition.condition]] if ( not prog - and "Achievement" not in flags - and "Location" not in flags - and "Item" not in flags + and "Achievement" not in rule.flags + and "Location" not in rule.flags + and "Item" not in rule.flags ): mark_progression( - conditions, progression, rules, rule_indices, loc_to_item + rule.conditions, progression, rules, rule_indices, loc_to_item ) - elif type == COND_LOC: - _, _, _, conditions = rules[rule_indices[condition]] - mark_progression(conditions, progression, rules, rule_indices, loc_to_item) - elif type == COND_GROUP: - _, conditions = condition + elif condition.type == COND_LOC: + + mark_progression( + rules[rule_indices[condition.condition]].conditions, + progression, + rules, + rule_indices, + loc_to_item, + ) + elif condition.type == COND_GROUP: + _, conditions = condition.condition mark_progression(conditions, progression, rules, rule_indices, loc_to_item) @@ -226,29 +264,7 @@ def read_data() -> Tuple[ # Goal to rule index that ends that goal's range and the locations required List[Tuple[int, Set[str]]], # Rules - List[ - Tuple[ - # Rule - str, - # Flag to flag arg - Dict[str, Union[str, int, None]], - # True = or, False = and, None = N/A - Union[bool, None], - # Conditions - List[ - Tuple[ - # True = positive, False = negative - bool, - # Condition type - int, - # Condition name or list (True = or, False = and, None = N/A) (list shares type with outer) - Union[str, Tuple[Union[bool, None], List]], - # Condition arg - Union[str, int, None], - ] - ], - ] - ], + List[Rule], # Rule to rule index Dict[str, int], # Label to rewards @@ -379,7 +395,7 @@ def read_data() -> Tuple[ unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv") elif pos == COND_OR_SEMI: if id == IDENT: - conditions.append((sign, COND_ITEM, token, None)) + conditions.append(Condition(sign, COND_ITEM, token, None)) sign = True pos = POST_COND elif id == HASH: @@ -424,14 +440,14 @@ def read_data() -> Tuple[ ) condition = operator, conditions sign, operator, conditions = outer.pop() - conditions.append((sign, COND_GROUP, condition, None)) + conditions.append(Condition(sign, COND_GROUP, condition, None)) sign = True pos = POST_COND else: unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv") elif pos == COND: if id == IDENT: - conditions.append((sign, COND_ITEM, token, None)) + conditions.append(Condition(sign, COND_ITEM, token, None)) sign = True pos = POST_COND elif id == HASH: @@ -449,7 +465,7 @@ def read_data() -> Tuple[ unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv") elif pos == LOC: if id == IDENT: - conditions.append((sign, COND_LOC, token, None)) + conditions.append(Condition(sign, COND_LOC, token, None)) sign = True pos = POST_COND else: @@ -464,10 +480,10 @@ def read_data() -> Tuple[ if id == LPAREN: pos = FN_ARG elif id == SEMI: - conditions.append((sign, COND_FN, function, None)) + conditions.append(Condition(sign, COND_FN, function, None)) pos = END elif id == AND: - conditions.append((sign, COND_FN, function, None)) + conditions.append(Condition(sign, COND_FN, function, None)) sign = True if operator is True: raise Exception( @@ -476,7 +492,7 @@ def read_data() -> Tuple[ operator = False pos = COND elif id == OR: - conditions.append((sign, COND_FN, function, None)) + conditions.append(Condition(sign, COND_FN, function, None)) sign = True if operator is False: raise Exception( @@ -485,21 +501,21 @@ def read_data() -> Tuple[ operator = True pos = COND elif id == RPAREN: - conditions.append((sign, COND_FN, function, None)) + conditions.append(Condition(sign, COND_FN, function, None)) if not outer: raise Exception( f"found `)` at {line + 1}:{char + 1} without matching `(`" ) condition = operator, conditions sign, operator, conditions = outer.pop() - conditions.append((sign, COND_GROUP, condition, None)) + conditions.append(Condition(sign, COND_GROUP, condition, None)) sign = True pos = POST_COND else: unexpected(line, char, id, token, pos, POS_FMT, "Rules.dsv") elif pos == FN_ARG: if id == IDENT or id == NUM: - conditions.append((sign, COND_FN, function, token)) + conditions.append(Condition(sign, COND_FN, function, token)) sign = True pos = FN_ARG_END else: @@ -527,7 +543,33 @@ def read_data() -> Tuple[ f"rule `{name}` on line `{line + 1}` shadows a previous rule" ) rule_indices[name] = len(rules) - rules.append((name, flags, operator, conditions)) + rules.append(Rule(name, flags, operator, conditions)) + + for flag in flags: + if flag not in { + "Location", + "Item", + "Goal", + "Early", + "Achievement", + "Grindy", + "Fishing", + "Npc", + "Pickaxe", + "Hammer", + "Minions", + "Armor Minions", + "Mech Boss", + "Final Boss", + "Getfixedboi", + "Not Getfixedboi", + "Calamity", + "Not Calamity", + "Not Calamity Getfixedboi", + }: + raise Exception( + f"rule `{name}` on line `{line + 1}` has unrecognized flag `{flag}`" + ) if "Item" in flags: item_name = flags["Item"] or f"Post-{name}" @@ -558,7 +600,7 @@ def read_data() -> Tuple[ final_bosses.append(flags["Item"] or f"Post-{name}") final_boss_loc.append(name) - if (minions := flags.get("ArmorMinions")) is not None: + if (minions := flags.get("Armor Minions")) is not None: armor_minions[name] = minions if (minions := flags.get("Minions")) is not None: @@ -572,16 +614,19 @@ def read_data() -> Tuple[ goal_indices[goal] = len(goals) goals.append((len(rules), set())) - for name, flags, _, _ in rules: - if "Goal" in flags: - _, items = goals[ - goal_indices[ - name.translate(str.maketrans("", "", string.punctuation)) + for rule in rules: + if "Goal" in rule.flags: + if (name := rule.flags.get("Goal")) is not None: + goal_name = name + else: + goal_name = ( + rule.name.translate(str.maketrans("", "", string.punctuation)) .replace(" ", "_") .lower() - ] - ] - items.add(name) + ) + + _, items = goals[goal_indices[goal_name]] + items.add(rule.name) _, mech_boss_items = goals[goal_indices["mechanical_bosses"]] mech_boss_items.update(mech_boss_loc) @@ -589,24 +634,27 @@ def read_data() -> Tuple[ _, final_boss_items = goals[goal_indices["calamity_final_bosses"]] final_boss_items.update(final_boss_loc) - for name, _, _, conditions in rules: - validate_conditions(name, rule_indices, conditions) + for rule in rules: + validate_conditions(rule.name, rule_indices, rule.conditions) - for name, flags, _, conditions in rules: + for rule in rules: prog = False if ( - "Npc" in flags - or "Goal" in flags - or "Pickaxe" in flags - or "Hammer" in flags - or "Mech Boss" in flags - or "Minions" in flags - or "ArmorMinions" in flags + "Npc" in rule.flags + or "Goal" in rule.flags + or "Pickaxe" in rule.flags + or "Hammer" in rule.flags + or "Mech Boss" in rule.flags + or "Final Boss" in rule.flags + or "Minions" in rule.flags + or "Armor Minions" in rule.flags ): - progression.add(loc_to_item[name]) + progression.add(loc_to_item[rule.name]) prog = True - if prog or "Location" in flags or "Achievement" in flags: - mark_progression(conditions, progression, rules, rule_indices, loc_to_item) + if prog or "Location" in rule.flags or "Achievement" in rule.flags: + mark_progression( + rule.conditions, progression, rules, rule_indices, loc_to_item + ) # Will be randomized via `slot_randoms` / `self.multiworld.random` label = None @@ -685,16 +733,15 @@ def read_data() -> Tuple[ next_id += 1 item_name_to_id["Reward: Coins"] = next_id - item_name_to_id["Victory"] = next_id + 1 - next_id += 2 + next_id += 1 location_name_to_id = {} - for name, flags, _, _ in rules: - if "Location" in flags or "Achievement" in flags: - if name in location_name_to_id: - raise Exception(f"location `{name}` shadows a previous location") - location_name_to_id[name] = next_id + for rule in rules: + if "Location" in rule.flags or "Achievement" in rule.flags: + if rule.name in location_name_to_id: + raise Exception(f"location `{rule.name}` shadows a previous location") + location_name_to_id[rule.name] = next_id next_id += 1 return ( diff --git a/worlds/terraria/Options.py b/worlds/terraria/Options.py index 4c4b96056c..f5056e4a06 100644 --- a/worlds/terraria/Options.py +++ b/worlds/terraria/Options.py @@ -1,40 +1,70 @@ from dataclasses import dataclass -from Options import Choice, DeathLink, PerGameCommonOptions +from Options import Choice, DeathLink, PerGameCommonOptions, Toggle, DefaultOnToggle + + +class Calamity(Toggle): + """Calamity mod bosses and events are shuffled""" + + display_name = "Calamity Mod Integration" + + +class Getfixedboi(Toggle): + """Generation accomodates the secret, very difficult "getfixedboi" seed""" + + display_name = """"getfixedboi" Seed""" class Goal(Choice): - """The victory condition for your run. Stuff after the goal will not be shuffled.""" + """ + The victory condition for your run. Stuff after the goal will not be shuffled. + Primordial Wyrm and Boss Rush are accessible relatively early, so consider "Items" or + "Locations" accessibility to avoid getting stuck on the goal. + """ display_name = "Goal" option_mechanical_bosses = 0 - # option_calamitas_clone = 1 + option_calamitas_clone = 1 option_plantera = 2 option_golem = 3 option_empress_of_light = 4 option_lunatic_cultist = 5 - # option_astrum_deus = 6 + option_astrum_deus = 6 option_moon_lord = 7 - # option_providence_the_profaned_goddess = 8 - # option_devourer_of_gods = 9 - # option_yharon_dragon_of_rebirth = 10 + option_providence_the_profaned_goddess = 8 + option_devourer_of_gods = 9 + option_yharon_dragon_of_rebirth = 10 option_zenith = 11 - # option_calamity_final_bosses = 12 - # option_adult_eidolon_wyrm = 13 + option_calamity_final_bosses = 12 + option_primordial_wyrm = 13 + option_boss_rush = 14 default = 0 -class Achievements(Choice): +class EarlyAchievements(DefaultOnToggle): + """Adds checks upon collecting early Pre-Hardmode achievements. Adds many sphere 1 checks.""" + + display_name = "Early Pre-Hardmode Achievements" + + +class NormalAchievements(DefaultOnToggle): """ - Adds checks upon collecting achievements. Achievements for clearing bosses and events are excluded. - "Exclude Grindy" also excludes fishing achievements. + Adds checks upon collecting achivements not covered by the other options. Achievements for + clearing bosses and events are excluded. """ - display_name = "Achievements" - option_none = 0 - option_exclude_grindy = 1 - option_exclude_fishing = 2 - option_all = 3 - default = 1 + display_name = "Normal Achievements" + + +class GrindyAchievements(Toggle): + """Adds checks upon collecting grindy achievements""" + + display_name = "Grindy Achievements" + + +class FishingAchievements(Toggle): + """Adds checks upon collecting fishing quest achievements""" + + display_name = "Fishing Quest Achievements" class FillExtraChecksWith(Choice): @@ -51,7 +81,12 @@ class FillExtraChecksWith(Choice): @dataclass class TerrariaOptions(PerGameCommonOptions): + calamity: Calamity + getfixedboi: Getfixedboi goal: Goal - achievements: Achievements + early_achievements: EarlyAchievements + normal_achievements: NormalAchievements + grindy_achievements: GrindyAchievements + fishing_achievements: FishingAchievements fill_extra_checks_with: FillExtraChecksWith death_link: DeathLink diff --git a/worlds/terraria/Rewards.dsv b/worlds/terraria/Rewards.dsv index dbae37b449..c8fb969689 100644 --- a/worlds/terraria/Rewards.dsv +++ b/worlds/terraria/Rewards.dsv @@ -121,10 +121,9 @@ Corrupt Flask; Calamity; Crimson Flask; Calamity; Craw Carapace; Calamity; Giant Shell; Calamity; -Fungal Carapace; Calamity; Life Jelly; Calamity; Vital Jelly; Calamity; -Mana Jelly; Calamity; +Cleansing Jelly; Calamity; Giant Tortoise Shell; Calamity; Coin of Deceit; Calamity; Ink Bomb; Calamity; @@ -151,4 +150,11 @@ Depths Charm; Calamity; Anechoic Plating; Calamity; Iron Boots; Calamity; Sprit Glyph; Calamity; -Abyssal Amulet; Calamity; \ No newline at end of file +Abyssal Amulet; Calamity; + +# unshuffled + +Life Crystal; +Enchanted Sword; +Starfury; +Defender Medal; \ No newline at end of file diff --git a/worlds/terraria/Rules.dsv b/worlds/terraria/Rules.dsv index 322bf9c5d3..9ae82d7472 100644 --- a/worlds/terraria/Rules.dsv +++ b/worlds/terraria/Rules.dsv @@ -1,43 +1,57 @@ -// TODO Calamity minion armor +// For the logic to account for all skips, these rules would need to be made much more comprehensive // Starting gear Copper Shortsword; Guide; Npc; // Immediately accessible -Timber!!; Achievement; -Benched; Achievement; -Stop! Hammer Time!; Achievement; -Matching Attire; Achievement; -Fashion Statement; Achievement; -Ooo! Shiny!; Achievement; -No Hobo; Achievement; +Squire Slime; Npc; +Traveling Merchant; ; @npc(2); +Lifeform Analyzer; ; Traveling Merchant; +DPS Meter; ; Traveling Merchant | (@calamity & Wire); +Stopwatch; ; Traveling Merchant; +Timber!!; Achievement | Early; +Benched; Achievement | Early; +Stop! Hammer Time!; Achievement | Early; +Matching Attire; Achievement | Early; +Fashion Statement; Achievement | Early; +Ooo! Shiny!; Achievement | Early; +No Hobo; Achievement | Early; // When NPC shuffling is added, this shouldn't be considered early Merchant; Npc; Bug Net; ; @calamity | Merchant; -Heavy Metal; Achievement; -Nurse; Npc; Merchant; -The Frequent Flyer; Achievement; Nurse; -Demolitionist; Npc; Merchant; +Heavy Metal; Achievement | Early; Dye Trader; Npc; @npc(4); Dye Hard; Achievement; Dye Trader; -Lucky Break; Achievement; -Star Power; Achievement; -You Can Do It!; Achievement; +Demolitionist; Npc; Merchant; +Lucky Break; Achievement | Early; +Star Power; Achievement | Early; +You Can Do It!; Achievement | Early; +Wulfrum Battery; Calamity; +Wulfrum Armor; Calamity | Armor Minions(1); // Surface exploration +Cactus; +Unusual Survival Strategies; Achievement | Early; Aglet; +Radar; +Wand of Sparking; Heliophobia; Achievement; Blighted Gel; Calamity; +Evil Powder; Archaeologist; Achievement | Grindy; Zoologist; Npc; Cat; Npc; Zoologist; Feeling Petty; Achievement; Cat | Dog; Dog; Npc; Zoologist; +Painter; Npc; @npc(8); A Rather Blustery Day; Achievement | Grindy; Enchanted Sword; Pretty in Pink; Achievement | Grindy; Marathon Medalist; Achievement | Grindy; Angler; Npc; +Fisherman's Pocket Guide; ; Angler | Weather Radio; +Weather Radio; ; Angler | Sextant; +Sextant; ; Angler | Fisherman's Pocket Guide; Servant-in-Training; Achievement | Fishing; Angler; \10 Fishing Quests; Achievement | Fishing; Angler; Trout Monkey; Achievement | Fishing; Angler; @@ -45,52 +59,69 @@ Glorious Golden Pole; Achievement | Fishing; Fast and Fishious; Achievement | Fishing; Angler; Supreme Helper Minion!; Achievement | Fishing; Angler; Water Walking Boots; -Painter; Npc; @npc(8); +Aquatic Heart; Calamity; // Sky exploration -Into Orbit; Achievement; +Into Orbit; Achievement | Early; Mysterious Circuitry; Calamity; Dubious Plating; Calamity; Charging Station; Calamity; Codebreaker Base; Calamity; Starfury; +Celestial Magnet; +Clumsy Slime; Npc; // Underground -Watch Your Step!; Achievement; -Throwing Lines; Achievement; +Watch Your Step!; Achievement | Early; +Throwing Lines; Achievement | Early; Torch God; Location | Item(Reward: Torch God's Favor); -Vehicular Manslaughter; Achievement; -Hey! Listen!; Achievement; +Vehicular Manslaughter; Achievement | Early; +Ancient Bone Dust; Calamity; +Depth Meter; +Compass; +Hey! Listen!; Achievement | Early; I Am Loot!; Achievement; +Magic Mirror; Heart Breaker; Achievement; -Hold on Tight!; Achievement; +Nurse; Npc; Merchant; +The Frequent Flyer; Not Calamity Getfixedboi | Achievement; Nurse; +Feast of Midas; Achievement; Bug Net; +Hold on Tight!; Achievement | Early; Feller of Evergreens; Calamity; Gold Hammer; Hammer(55); Gold Pickaxe; Pickaxe(55); +Gold Watch; Like a Boss; Achievement; Hermes Boots; Jeepers Creepers; Achievement; Stylist; Npc; Funkytown; Achievement; Deceiver of Fools; Achievement | Grindy; +Metal Detector; Dead Men Tell No Tales; Achievement; Bulldozer; Achievement | Grindy; // Cavern Obsidian; Obsidian Skull; ; Obsidian; +Raider's Talisman; Calamity; Obsidian; There are Some Who Call Him...; Achievement | Grindy; Lava Charm; Demonite Ore; -Demonite Bar; ; Demonite Ore | (@calamity & #Calamity Evil Boss); +Demonite Bar; ; Demonite Ore; Evil Sword; ; Demonite Bar; +Coin of Deceit; Calamity; Demonite Bar | Ruin Medallion; // Underground Ice Ice Skates; -Flinx Fur Coat; ArmorMinions(1); +Flinx Fur Coat; Armor Minions(1); // Underground Desert +Stormlion Mandible; Calamity; Golfer; Npc; +Party Girl; Npc; @npc(14); +Jolly Jamboree; Achievement | Grindy; Party Girl; +Cool Slime; Npc; Party Girl; // Sunken Sea Sea Prism; Calamity; @@ -98,37 +129,50 @@ Navyplate; Calamity; // Underground Jungle Anklet of the Wind; +Feral Claws; Stinger; Jungle Spores; Vine; Blade of Grass; ; Stinger & Jungle Spores & Vine; +Nature's Gift; +Bezoar; Summoning Potion; Minions(1); +// The Aether +A Shimmer In The Dark; Achievement; + // Underworld It's Getting Hot in Here; Achievement; Rock Bottom; Achievement; Obsidian Rose; Havocplate; Calamity; +Magma Stone; // Evil Smashing, Poppet!; Achievement; Arms Dealer; Npc; Leading Landlord; Achievement; Nurse & Arms Dealer; // The logic is way more complex, but that doesn't affect anything Completely Awesome; Achievement; Arms Dealer; +Illegal Gun Parts; ; Arms Dealer | Flamethrower; + +// Abyss +Ink Bomb; Calamity; // King Slime King Slime; Location | Item; -Sticky Situation; Achievement | Grindy; +Sticky Situation; Not Getfixedboi | Achievement | Grindy; The Cavalry; Achievement; Solidifier; ; #King Slime; +Nerdy Slime; Npc; #King Slime; // Desert Scourge Desert Scourge; Calamity | Location | Item; Pearl Shard; Calamity; #Desert Scourge; Sea Remains; Calamity; Pearl Shard; Reefclaw Hamaxe; Calamity | Hammer(60); Sea Remains; +Victide Armor; Calamity | Armor Minions(1); Sea Remains; Sandstorm; ; ~@calamity | Desert Scourge; -Voltaic Jelly; Calamity | Minions(1); Desert Scourge; // Jelly-Charged Battery doesn't stack. This is the case for all Calamity minion accessory upgrades. +Voltaic Jelly; Calamity | Minions(1); Desert Scourge | Jelly-Charged Battery; // Jelly-Charged Battery doesn't stack. This is the case for all Calamity minion accessory upgrades. // Giant Clam Giant Clam; Calamity | Location | Item; Desert Scourge; @@ -136,18 +180,21 @@ Amidias; Calamity; // Blood Moon Bloodbath; Achievement | Grindy; +Blood Orb; Calamity; +Shark Tooth Necklace; Til Death...; Achievement | Grindy; -Quiet Neighborhood; Achievement; +Quiet Neighborhood; Achievement | Early; +Surly Slime; Npc; // Eye of Cthulhu Eye of Cthulhu; Location | Item; Dryad; Npc; Eye of Cthulhu | Evil Boss | Skeletron; Pumpkin Seeds; ; Dryad; -Pumpkin; ; Pumpkin Seeds; -Purification Powder; ; Dryad; // Shimmered from Evil Powder in 1.4.4. Not bought from Dryad in get fixed boi. -Party Girl; Npc; @npc(14); -Jolly Jamboree; Achievement | Grindy; Party Girl; -Acid Rain Tier 1; Calamity | Location | Item; Eye of Cthulhu; +Pumpkin; ; Pumpkin Seeds | Cactus; +Purification Powder; ; (~@getfixedboi & Dryad) | Evil Powder; +Mystic Slime; Npc; Purification Powder; +And Good Riddance!; Achievement | Grindy; Dryad; +Acid Rain Tier 1; Calamity | Location | Item; Eye of Cthulhu | Wall of Flesh | Aquatic Scourge; // Crabulon Crabulon; Calamity | Location | Item; @@ -156,112 +203,160 @@ Crabulon; Calamity | Location | Item; Evil Boss; Location | Item; Evil Boss Part; ; #Evil Boss; Evil Pickaxe; Pickaxe(65); Evil Boss Part; -Obsidian Armor; ArmorMinions(1); Obsidian & Evil Boss Part; +Obsidian Armor; Armor Minions(1); Obsidian & Evil Boss Part; Tavernkeep; Npc; Evil Boss; Old One's Army Tier 1; Location | Item; Tavernkeep; -Meteorite; ; Evil Boss; -Meteorite Bar; ; Meteorite; +Meteorite; ; #Evil Boss | Evil Boss | Meteorite Bar | (@calamity & Astral Infection); +Meteorite Bar; ; Meteorite | (@calamity & Astral Infection) | Meteor Staff; Meteor Hamaxe; Hammer(60); Meteorite Bar; Hellforge; ; @pickaxe(60); -Hellstone; ; @pickaxe(65) | Wall of Flesh; +Hellstone; ; @pickaxe(65) | Wall of Flesh | Hellstone Bar; Hellstone Bar; ; Hellstone; Fiery Greatsword; ; Hellstone Bar; Molten Hamaxe; Hammer(70); Hellstone Bar; Molten Pickaxe; Pickaxe(100); Hellstone Bar; Miner for Fire; Achievement; Molten Pickaxe; -Hot Reels!; Achievement; Hellstone Bar & Bug Net; // TODO Calamity +Hot Reels!; Achievement; Hellstone Bar & (@calamity | Bug Net); Brimstone Slag; Calamity; @pickaxe(100); // Goblin Army Goblin Army; Location | Item; Goblin Tinkerer; Npc; Goblin Army; Tinkerer's Workshop; ; Goblin Tinkerer; +Mana Flower; ; (Tinkerer's Workshop & Nature's Gift) | (@calamity & Ethereal Talisman); +Silencing Sheath; Calamity; (Tinkerer's Workshop & Demonite Bar & Evil Boss Part) | Dark Matter Sheath; Rocket Boots; ; Goblin Tinkerer; Spectre Boots; ; Tinkerer's Workshop & Hermes Boots & Rocket Boots; Lightning Boots; ; Tinkerer's Workshop & Spectre Boots & Anklet of the Wind & Aglet; Frostspark Boots; ; Tinkerer's Workshop & Lightning Boots & Ice Skates; Lava Waders; ; Tinkerer's Workshop & Obsidian Skull & Lava Charm & Obsidian Rose & Water Walking Boots; Terraspark Boots; ; Tinkerer's Workshop & Frostspark Boots & Lava Waders; +GPS; ; Tinkerer's Workshop & Depth Meter & Gold Watch & Compass; +Goblin Tech; ; Tinkerer's Workshop & DPS Meter & Stopwatch & Metal Detector; +Fish Finder; ; Tinkerer's Workshop & Fisherman's Pocket Guide & Weather Radio & Sextant; Boots of the Hero; Achievement | Grindy; Terraspark Boots; +Diving Gear; ; Tinkerer's Workshop; // Queen Bee Where's My Honey?; Achievement; Queen Bee; Location | Item; Bee Keeper; ; #Queen Bee; Bee Wax; ; #Queen Bee; -Bee Armor; ArmorMinions(2); Bee Wax; +Bee Armor; Armor Minions(2); Bee Wax; Not the Bees!; Achievement; #Queen Bee & Bee Armor; Witch Doctor; Npc; Queen Bee; -Pygmy Necklace; Minions(1); Witch Doctor; +Pygmy Necklace; Minions(1); Witch Doctor | (@calamity & Statis' Blessing); // Calamity Evil Boss -Calamity Evil Boss; Calamity | Location | Item; -Aerialite Ore; Calamity; Calamity Evil Boss & @pickaxe(65); -Aerialite Bar; Calamity; Aerialite Ore; +The Hive Mind; Calamity | Location | Item; +The Perforators; Calamity | Location | Item; +Blood Sample; Calamity; #The Perforators; +Aerialite Ore; Calamity; The Hive Mind | The Perforators | Cobalt Ore | Aerialite Bar; // No pick needed; can be fished +Aerialite Bar; Calamity; Aerialite Ore | Feather Crown; Aerial Hamaxe; Calamity | Hammer(70); Aerialite Bar; Skyfringe Pickaxe; Calamity | Pickaxe(75); Aerialite Bar; +Aerospec Armor; Calamity | Armor Minions(1); Aerialite Bar; +Feather Crown; Calamity; Aerialite Bar | Moonstone Crown; // Skeletron Skeletron; Location | Item; Clothier; Npc; Skeletron; Dungeon; ; Skeletron; Dungeon Heist; Achievement; Dungeon; -Bone; ; Dungeon | (@calamity & #Skeletron); -Bewitching Table; Minions(1); Dungeon | (Witch Doctor & Wizard); +Bone; ; Dungeon | (@calamity & (#Skeletron | (@getfixedboi & #Ravager) | Mirage Mirror)); +Mirage Mirror; Calamity; (Tinkerer's Workshop & Bone) | Abyssal Mirror; +Tally Counter; ; Dungeon; +R.E.K. 3000; ; Tinkerer's Workshop & Radar & Tally Counter & Lifeform Analyzer; +PDA; ; Tinkerer's Workshop & GPS & R.E.K. 3000 & Goblin Tech & Fish Finder; +Cell Phone; ; (Tinkerer's Workshop & Magic Mirror & PDA) | (@getfixedboi & @calamity & #Polterghast); +Black Mirror; Achievement | Grindy; Cell Phone; +Bewitching Table; Minions(1); Dungeon | (Witch Doctor & Wizard) | Alchemy Table; +Alchemy Table; ; Dungeon | Bewitching Table; Mechanic; ; Dungeon; -Wire; ; Mechanic; +Wire; ; Mechanic | (@calamity & Electrician's Glove); Decryption Computer; Calamity; Mysterious Circuitry & Dubious Plating & Wire; Actuator; ; Mechanic; Muramasa; ; Dungeon; +Cobalt Shield; ; Dungeon | (@calamity & Cobalt Bar); +Obsidian Shield; ; Tinkerer's Workshop & Cobalt Shield & Obsidian Skull; +Elder Slime; Npc; Skeletron & Dungeon; // Deerclops Deerclops; Location | Item; // The Slime God The Slime God; Calamity | Location | Item; Blighted Gel; -Purified Gel; Calamity; #The Slime God; +Purified Gel; Calamity; #The Slime God | Jelly-Charged Battery; +Jelly-Charged Battery; Calamity; (Wulfrum Battery & Voltaic Jelly & Purified Gel & Stormlion Mandible) | Star-Tainted Generator; Static Refiner; Calamity; Purified Gel & Solidifier; Gelpick; Calamity | Pickaxe(100); Static Refiner & Purified Gel & Blighted Gel; +Statigel Armor; Calamity | Armor Minions(1); Static Refiner & Purified Gel & Blighted Gel; Night's Edge; ; Evil Sword & Muramasa & Blade of Grass & Fiery Greatsword & (~@calamity | Purified Gel); // Wall of Flesh Wall of Flesh; Location | Item(Hardmode); Guide; Pwnhammer; Hammer(80); #Wall of Flesh; +Emblem; ; #Wall of Flesh | Avenger Emblem | (@calamity & (Mechanical Glove | Celestial Emblem | Statis' Blessing)); +Fast Clock; ; Wall of Flesh | Trifold Map | (@calamity & Wire & Pixie Dust & Soul of Light); Wizard; Npc; Wall of Flesh; -Tax Collector; Npc; Purification Powder & Wall of Flesh; +Titan Glove; ; Wall of Flesh | Power Glove; +Power Glove; ; Tinkerer's Workshop & Titan Glove & Feral Claws; +Magic Quiver; ; Wall of Flesh | (@calamity & Elemental Quiver); +Hallowed Seeds; ; (Wall of Flesh & Dryad) | Holy Water; +Armor Polish; ; Wall of Flesh | Vitamins | (@calamity & Bone & Ancient Bone Dust); +Adhesive Bandage; ; @calamity | Wall of Flesh; +Medicated Bandage; ; Tinkerer's Workshop & Bezoar & Adhesive Bandage; +Megaphone; ; Wall of Flesh | Nazar | (@calamity & Wire & Cobalt Bar); +Pocket Mirror; ; Wall of Flesh | Blindfold | (@calamity & Crystal Shard & Soul of Night); +Trifold Map; ; Wall of Flesh | Fast Clock | (@calamity & Soul of Light & Soul of Night); +The Plan; ; Tinkerer's Workshop & Trifold Map & Fast Clock; +Tax Collector; Npc; (Purification Powder & Wall of Flesh) | @getfixedboi; Spider Fangs; ; Wall of Flesh; -Spider Armor; ArmorMinions(3); Spider Fangs; +Spider Armor; Armor Minions(3); Spider Fangs; Cross Necklace; ; Wall of Flesh; Altar; ; Wall of Flesh & @hammer(80); Begone, Evil!; Achievement; Altar; -Cobalt Ore; ; (((~@calamity & Altar) | (@calamity & Wall of Flesh)) & @pickaxe(100)) | Wall of Flesh; +Cobalt Ore; ; (((~@calamity & Altar) | (@calamity & Wall of Flesh)) & @pickaxe(100)) | Wall of Flesh | Mythril Ore | Cobalt Bar; Extra Shiny!; Achievement; Cobalt Ore | Mythril Ore | Adamantite Ore | Chlorophyte Ore; -Cobalt Bar; ; Cobalt Ore | Wall of Flesh; +Cobalt Bar; ; Cobalt Ore | (@calamity & Lunic Eye) | Wall of Flesh; Cobalt Pickaxe; Pickaxe(110); Cobalt Bar; -Soul of Night; ; Wall of Flesh | (@calamity & Altar); +Blindfold; ; @calamity | Wall of Flesh | Pocket Mirror; +Reflective Shades; ; Tinkerer's Workshop & Blindfold & Pocket Mirror; +Vitamins; ; Wall of Flesh | Armor Polish | (@calamity & Alchemy Table & Blood Orb); +Armor Bracing; ; Tinkerer's Workshop & Vitamins & Armor Polish; +Nazar; ; Wall of Flesh | Megaphone | (@calamity & Soul of Night); +Countercurse Mantra; ; Tinkerer's Workshop & Nazar & Megaphone; +Ankh Charm; ; Tinkerer's Workshop & Reflective Shades & Armor Bracing & Medicated Bandage & Countercurse Mantra & The Plan; +Ankh Shield; ; Tinkerer's Workshop & Obsidian Shield & Ankh Charm; +Ankhumulation Complete; Achievement | Grindy; Ankh Shield; +Soul of Night; ; Wall of Flesh | (@calamity & (Altar | (@getfixedboi & #Duke Fishron))); Hallow; ; Wall of Flesh; -Pixie Dust; ; Hallow; +Pixie Dust; ; Hallow | Meteor Staff | Holy Water; +Holy Water; ; (Pixie Dust & Hallowed Seeds) | (@calamity & Statis' Blessing); Unicorn Horn; ; Hallow; Crystal Shard; ; Hallow; Axe of Purity; Calamity; Feller of Evergreens & Purification Powder & Pixie Dust & Crystal Shard; -Soul of Light; ; Hallow | (@calamity & #Queen Slime); +Fabsol's Vodka; Calamity; (Pixie Dust & Crystal Shard & Unicorn Horn) | (@getfixedboi & #Empress of Light); +Soul of Light; ; Hallow | (@calamity & (#Queen Slime | (@getfixedboi & #Duke Fishron))) | Light Disc | Meteor Staff; +Meteor Staff; ; (Hardmode Anvil & Meteorite Bar & Pixie Dust & Soul of Light) | Asteroid Staff; Blessed Apple; ; Hallow; Rod of Discord; ; Hallow; Gelatin World Tour; Achievement | Grindy; Dungeon & Wall of Flesh & Hallow & #King Slime; Soul of Flight; ; Wall of Flesh; -Head in the Clouds; Achievement; @grindy | (Soul of Flight & ((Hardmode Anvil & (Soul of Light | Soul of Night | Pixie Dust | Wall of Flesh | Solar Eclipse | @mech_boss(1) | Plantera | Spectre Bar | #Golem)) | (Shroomite Bar & Autohammer) | #Mourning Wood | #Pumpking)) | Steampunker | (Wall of Flesh & Witch Doctor) | (Solar Eclipse & Plantera) | #Everscream | #Old One's Army Tier 3 | #Empress of Light | #Duke Fishron | (Fragment & Luminite Bar & Ancient Manipulator); // Leaf Wings are Post-Plantera in 1.4.4 +Head in the Clouds; Achievement; @grindy | (Soul of Flight & ((Hardmode Anvil & (Soul of Light | Soul of Night | Pixie Dust | Wall of Flesh | Solar Eclipse | @mech_boss(1) | Plantera | Spectre Bar | #Golem)) | (Shroomite Bar & Autohammer) | #Mourning Wood | #Pumpking)) | Steampunker | (Wall of Flesh & Plantera & Witch Doctor) | (Solar Eclipse & Plantera) | #Everscream | #Old One's Army Tier 3 | #Empress of Light | #Duke Fishron | (Fragment & Luminite Bar & Ancient Manipulator); Bunny; Npc; Zoologist & Wall of Flesh; // Extremely simplified Forbidden Fragment; ; Sandstorm & Wall of Flesh; -Astral Infection; Calamity; Wall of Flesh; -Stardust; Calamity; Astral Infection | #Astrum Aureus | #Astrum Deus; +Astral Infection; Calamity; Wall of Flesh | Astrum Aureus; +Stardust; Calamity; Astral Infection | #Astrum Aureus | #Astrum Deus | Eye of Magnus | Meld Construct; +Lunic Eye; Calamity; (Cobalt Bar & Stardust) | Eye of Magnus; Trapper Bulb; Calamity; Wall of Flesh; Titan Heart; Calamity; Astral Infection; Essence of Sunlight; Calamity; Wall of Flesh | Golem; -Essence of Eleum; Calamity; Wall of Flesh | Cryogen | #Cryogen; // TODO Check -Essence of Havoc; Calamity; Wall of Flesh | #Calamitas Clone | #Brimstone Elemental; -Don't Dread on Me; Achievement; Wall of Flesh; -Earth Elemental; Calamity | Location | Item; Wall of Flesh; -Cloud Elemental; Calamity | Location | Item; Wall of Flesh; +Essence of Eleum; Calamity; Wall of Flesh | Cryogen | #Cryogen | (@getfixedboi & #Duke Fishron); +Essence of Havoc; Calamity; Wall of Flesh | #Calamitas Clone | #Brimstone Elemental | Ruin Medallion; +Dreadnautilus; Calamity | Location | Item; Wall of Flesh; +Don't Dread on Me; Not Calamity | Achievement; Wall of Flesh; +Hardmode Giant Clam; Calamity | Location | Item; #Giant Clam & Wall of Flesh; Truffle; Npc; Wall of Flesh; It Can Talk?!; Achievement; Truffle; The First Shadowflame; Calamity | Minions(1); Goblin Army | Wall of Flesh; @@ -271,103 +366,125 @@ Pirate Invasion; Location | Item; Pirate; Npc; Pirate Invasion; // Queen Slime -Queen Slime; Location | Item; Hallow; +Queen Slime; Location | Item; Hallow | (@getfixedboi & @calamity & #Supreme Alchemist, Cirrus); +Sparkle Slime Balloon; ; #Queen Slime; +Diva Slime; Npc; Sparkle Slime Balloon; +The Great Slime Mitosis; Achievement; Nerdy Slime & Cool Slime & Elder Slime & Clumsy Slime & Diva Slime & Surly Slime & Mystic Slime & Squire Slime; // Aquatic Scourge -Mythril Ore; ; (((~@calamity & Altar) | (@calamity & @mech_boss(1))) & @pickaxe(110)) | (Wall of Flesh & (~@calamity | @mech_boss(1))); -Mythril Bar; ; Mythril Ore | (Wall of Flesh & (~@calamity | @mech_boss(1))); +Mythril Ore; ; (((~@calamity & Altar) | (@calamity & @mech_boss(1))) & @pickaxe(110)) | Wall of Flesh | Adamantite Ore | Mythril Bar; +Mythril Bar; ; Mythril Ore | Wall of Flesh | (@calamity & Electrician's Glove); Hardmode Anvil; ; Mythril Bar; Mythril Pickaxe; Pickaxe(150); Hardmode Anvil & Mythril Bar; -Adamantite Ore; ; (((~@calamity & Altar) | (@calamity & @mech_boss(2))) & @pickaxe(150)) | (Wall of Flesh & (~@calamity | @mech_boss(2))); +Electrician's Glove; Calamity; (Hardmode Anvil & Wire & Mythril Bar) | Nanotech; +Adamantite Ore; ; (((~@calamity & Altar) | (@calamity & @mech_boss(2))) & @pickaxe(150)) | Wall of Flesh | (~@calamity & Chlorophyte Ore) | Adamantite Bar | (@calamity & Hallowed Ore); Hardmode Forge; ; Hardmode Anvil & Adamantite Ore & Hellforge; -Adamantite Bar; ; (Hardmode Forge & Adamantite Ore) | (Wall of Flesh & (~@calamity | @mech_boss(2))); +Adamantite Bar; ; (Hardmode Forge & Adamantite Ore) | Wall of Flesh; Adamantite Pickaxe; Pickaxe(180); Hardmode Anvil & Adamantite Bar; -Forbidden Armor; ArmorMinions(2); Hardmode Anvil & Adamantite Bar & Forbidden Fragment; +Forbidden Armor; Armor Minions(2); Hardmode Anvil & Adamantite Bar & Forbidden Fragment; Aquatic Scourge; Calamity | Location | Item; -The Twins; Location | Item | Mech Boss; (@calamity | Hardmode Anvil) & Soul of Light; -Brimstone Elemental; Calamity | Location | Item; Soul of Night & Essence of Havoc & Unholy Core; -The Destroyer; Location | Item | Mech Boss; (@calamity | Hardmode Anvil) & Soul of Night; -Cryogen; Calamity | Location | Item; Soul of Night & Soul of Light & Essence of Eleum; -Skeletron Prime; Location | Item | Mech Boss; (@calamity | Hardmode Anvil) & Soul of Night & Soul of Light & Bone; -# mechanical_bosses Cragmaw Mire; Calamity | Location | Item; #Acid Rain Tier 2; -Nuclear Rod; Calamity | Minions(1); #Cragmaw Mire; -Acid Rain Tier 2; Calamity | Location | Item; #Acid Rain Tier 1 & Aquatic Scourge; +Nuclear Fuel Rod; Calamity | Minions(1); #Cragmaw Mire | Star-Tainted Generator; +Acid Rain Tier 2; Calamity | Location | Item; #Acid Rain Tier 1 & (Aquatic Scourge | Acid Rain Tier 3); +Mechanical Eye; ; (@calamity | Hardmode Anvil) & Soul of Light; +Mechanical Worm; ; (@calamity | Hardmode Anvil) & Soul of Night; +Mechanical Skull; ; (@calamity | Hardmode Anvil) & Soul of Night & Soul of Light & Bone; +Ocram's Razor; Getfixedboi; (@calamity | Hardmode Anvil) & Mechanical Eye & Mechanical Worm & Mechanical Skull; +The Twins; Location | Item | Mech Boss; (~@getfixedboi & Mechanical Eye) | (@getfixedboi & Ocram's Razor); +Brimstone Elemental; Calamity | Location | Item; Soul of Night & Essence of Havoc & Unholy Core; +The Destroyer; Location | Item | Mech Boss; (~@getfixedboi & Mechanical Worm) | (@getfixedboi & Ocram's Razor); +Cryogen; Calamity | Location | Item; Soul of Night & Soul of Light & Essence of Eleum; +Skeletron Prime; Location | Item | Mech Boss; (~@getfixedboi & Mechanical Skull) | (@getfixedboi & Ocram's Razor); +# mechanical_bosses // The Twins -Soul of Sight; ; #The Twins; +Soul of Sight; ; #The Twins | Avenger Emblem | (@calamity & (Mechanical Glove | Celestial Emblem)); Steampunker; Npc; @mech_boss(1); Hammush; ; Truffle & @mech_boss(1); Rainbow Rod; ; Hardmode Anvil & Crystal Shard & Unicorn Horn & Pixie Dust & Soul of Light & Soul of Sight; Prismancer; Achievement; Rainbow Rod; Long Ranged Sensor Array; Calamity; Hardmode Anvil & Mysterious Circuitry & Dubious Plating & Mythril Bar & Wire & Decryption Computer & Codebreaker Base; Hydraulic Volt Crusher; Calamity; Hardmode Anvil & Mysterious Circuitry & Dubious Plating & Mythril Bar & Soul of Sight; -Life Fruit; ; (@mech_boss(1) & Wall of Flesh) | (@calamity & (Living Shard | Wall of Flesh)); +Life Fruit; ; (@mech_boss(1) & Wall of Flesh) | (@calamity & (Living Shard | Wall of Flesh | (@getfixedboi & #Plantera))); Get a Life; Achievement; Life Fruit; Topped Off; Achievement; Life Fruit; Old One's Army Tier 2; Location | Item; #Old One's Army Tier 1 & ((Wall of Flesh & @mech_boss(1)) | #Old One's Army Tier 3); // Brimstone Elemental Infernal Suevite; Calamity; @pickaxe(150) | Brimstone Elemental; -Unholy Core; Calamity; Infernal Suevite & Hellstone; +Unholy Core; Calamity; (Infernal Suevite & Hellstone) | Brimstone Elemental; +Ruin Medallion; Calamity; (Hardmode Anvil & Coin of Deceit & Unholy Core & Essence of Havoc) | Dark Matter Sheath; // The Destroyer -Soul of Might; ; #The Destroyer; +Soul of Might; ; #The Destroyer | Avenger Emblem | Light Disc | (@calamity & (Mechanical Glove | Celestial Emblem)); // Cryogen -Cryonic Ore; Calamity; Cryogen & (@pickaxe(180) | @mech_boss(2)); -Cryonic Bar; Calamity; (Hardmode Forge & Cryonic Ore) | Fleshy Geode | Necromantic Geode; +Cryonic Ore; Calamity; (Cryogen & (@pickaxe(180) | @mech_boss(2))) | Cryonic Bar; +Cryonic Bar; Calamity; (Hardmode Forge & Cryonic Ore) | Fleshy Geode | Necromantic Geode | (Cryogen & @mech_boss(2)) | Life Alloy; Abyssal Warhammer; Calamity | Hammer(88); Hardmode Anvil & Cryonic Bar; Shardlight Pickaxe; Calamity | Pickaxe(180); Hardmode Anvil & Cryonic Bar; +Daedalus Armor; Calamity | Armor Minions(2); Hardmode Anvil & Cryonic Bar & Essence of Eleum; // Skeletron Prime -Soul of Fright; ; #Skeletron Prime; +Soul of Fright; ; #Skeletron Prime | Avenger Emblem | Flamethrower | (@calamity & (Mechanical Glove | Celestial Emblem)); Inferna Cutter; Calamity; Hardmode Anvil & Axe of Purity & Soul of Fright & Essence of Havoc; +Flamethrower; ; (Hardmode Anvil & Illegal Gun Parts & Soul of Fright) | (@getfixedboi & @calamity & #Skeletron); Buckets of Bolts; Achievement; #The Twins & #The Destroyer & #Skeletron Prime; -Mecha Mayhem; Achievement; #The Twins & #The Destroyer & #Skeletron Prime; -Hallowed Bar; ; (#The Twins | #The Destroyer | #Skeletron Prime) & (~@calamity | @mech_boss(3)); // Can't count on Hallowed Ore, since the player may be in prehardmode (TODO Check this) -Hallowed Armor; ArmorMinions(3); Hardmode Anvil & Hallowed Bar; +Mecha Mayhem; Achievement | Not Getfixedboi; #The Twins & #The Destroyer & #Skeletron Prime; +Hallowed Ore; Calamity; (@mech_boss(3) & @pickaxe(180)) | Chlorophyte Ore | Hallowed Bar; +Hallowed Bar; ; ((#The Twins | #The Destroyer | #Skeletron Prime) & (~@calamity | @mech_boss(3))) | (@calamity & Hardmode Forge & Hallowed Ore) | Light Disc; +Hallowed Armor; Armor Minions(3); Hardmode Anvil & Hallowed Bar; Excalibur; ; Hardmode Anvil & Hallowed Bar; Pickaxe Axe; Pickaxe(200); Hardmode Anvil & Hallowed Bar & Soul of Fright & Soul of Might & Soul of Sight; Drax Attax; Achievement; Pickaxe Axe; True Night's Edge; ; Hardmode Anvil & Night's Edge & Soul of Fright & Soul of Might & Soul of Sight; -Chlorophyte Ore; ; Wall of Flesh & @pickaxe(200); +Avenger Emblem; ; (Tinkerer's Workshop & Emblem & Soul of Might & Soul of Sight & Soul of Fright) | (@calamity & (Sand Shark Tooth Necklace | Sigil of Calamitas)) | (~@calamity & (Mechanical Glove | Celestial Emblem)); +Mechanical Glove; ; (Power Glove & ((~@calamity & Tinkerer's Workshop & Avenger Emblem) | (@calamity & Emblem & Soul of Fright & Soul of Might & Soul of Sight))) | Fire Gauntlet; +Celestial Emblem; ; (Celestial Magnet & ((~@calamity & Tinkerer's Workshop & Avenger Emblem) | (@calamity & Emblem & Soul of Fright & Soul of Might & Soul of Sight))) | Sigil of Calamitas; +Light Disc; ; (Hallowed Bar & Soul of Light & Soul of Might) | (@getfixedboi & @calamity & #Evil Boss); +Chlorophyte Ore; ; (Wall of Flesh & @pickaxe(200)) | (~@calamity & Luminite) | Chlorophyte Bar | (@calamity & Perennial Ore); Photosynthesis; Achievement; Chlorophyte Ore; -Chlorophyte Bar; ; Hardmode Forge & Chlorophyte Ore; +Chlorophyte Bar; ; (Hardmode Forge & Chlorophyte Ore) | Spectre Bar | Shroomite Bar; True Excalibur; ; Hardmode Anvil & Excalibur & Chlorophyte Bar; Chlorophyte Pickaxe; Pickaxe(200); Hardmode Anvil & Chlorophyte Bar; Chlorophyte Warhammer; Hammer(90); Hardmode Anvil & Chlorophyte Bar; // Calamitas Clone Calamitas Clone; Calamity | Location | Item | Goal; Hardmode Anvil & Hellstone Bar & Essence of Havoc; -Plantera; Location | Item | Goal; Wall of Flesh & (@mech_boss(3) | (@calamity & Hardmode Anvil & Trapper Bulb)); +Plantera; Location | Item | Goal; Wall of Flesh & (@mech_boss(3) | @calamity); # calamitas_clone # plantera -Ashes of Calamity; Calamity; #Calamitas Clone; +Ashes of Calamity; Calamity; #Calamitas Clone | Sigil of Calamitas; +Depth Cells; Calamity; Calamitas Clone | Abyssal Mirror; +Lumenyl; Calamity; Calamitas Clone | Abyssal Mirror; +Abyssal Mirror; Calamity; (Hardmode Anvil & Mirage Mirror & Ink Bomb & Depth Cells & Lumenyl) | Eclipse Mirror; +Fathom Swarmer Armor; Calamity | Armor Minions(2); Hardmode Anvil & Sea Remains & Depth Cells; // Plantera The Axe; Hammer(100); #Plantera; Seedler; ; #Plantera; Living Shard; Calamity; #Plantera; -Tiki Armor; ArmorMinions(4); Witch Doctor & Wall of Flesh & Plantera; +Tiki Armor; Armor Minions(4); Witch Doctor & Wall of Flesh & Plantera; Hercules Beetle; ; Witch Doctor & Wall of Flesh & Plantera; You and What Army?; Achievement; @minions(8); Cyborg; Npc; Plantera; +To Infinity... and Beyond!; Achievement; Cyborg & Wall of Flesh; Autohammer; ; Truffle & Plantera; Shroomite Bar; ; Autohammer & Chlorophyte Bar; Shroomite Digging Claw; Pickaxe(200); Hardmode Anvil & Shroomite Bar; Princess; Npc; Guide & Merchant & Nurse & Demolitionist & Dye Trader & Zoologist & Angler & Painter & Stylist & Golfer & Arms Dealer & Dryad & Party Girl & Tavernkeep & Goblin Tinkerer & Witch Doctor & Clothier & Wizard & Truffle & Tax Collector & Pirate & Steampunker & Cyborg; Real Estate Agent; Achievement; Princess; -Ectoplasm; ; ((Dungeon & Wall of Flesh) | @calamity) & Plantera; +Ectoplasm; ; (((Dungeon & Wall of Flesh) | @calamity) & Plantera) | Spectre Bar; Paladin's Shield; ; Dungeon & Wall of Flesh & Plantera; -Core of Sunlight; Calamity; (Hardmode Anvil & Essence of Sunlight & Ectoplasm) | Fleshy Geode | Necromantic Geode; -Core of Eleum; Calamity; (Hardmode Anvil & Essence of Eleum & Ectoplasm) | Fleshy Geode | Necromantic Geode; -Core of Havoc; Calamity; (Hardmode Anvil & Essence of Havoc & Ectoplasm) | Fleshy Geode | Necromantic Geode; -Core of Calamity; Calamity; (Hardmode Anvil & Core of Sunlight & Core of Eleum & Core of Havoc & Ashes of Calamity) | Necromantic Geode; +Core of Sunlight; Calamity; (Hardmode Anvil & Essence of Sunlight & Ectoplasm) | Fleshy Geode | Necromantic Geode | Core of Calamity | Statis' Blessing; +Core of Eleum; Calamity; (Hardmode Anvil & Essence of Eleum & Ectoplasm) | Fleshy Geode | Necromantic Geode | Core of Calamity; +Core of Havoc; Calamity; (Hardmode Anvil & Essence of Havoc & Ectoplasm) | Fleshy Geode | Necromantic Geode | Core of Calamity; +Core of Calamity; Calamity; (Hardmode Anvil & Core of Sunlight & Core of Eleum & Core of Havoc & Ashes of Calamity) | Necromantic Geode | Deadshot Brooch; +Deadshot Brooch; Calamity; (Hardmode Anvil & Emblem & Core of Calamity) | Elemental Quiver; Spectre Bar; ; Hardmode Forge & Chlorophyte Bar & Ectoplasm; Spectre Pickaxe; Pickaxe(200); Hardmode Anvil & Spectre Bar; Spectre Hamaxe; Hammer(90); Hardmode Anvil & Spectre Bar; -Robbing the Grave; Achievement; Dungeon & Plantera; +Robbing the Grave; Achievement; Dungeon & Wall of Flesh & Plantera; Evil Key; ; Plantera | (@calamity & #Wall of Flesh); Frozen Key; ; Plantera | (@calamity & #Cryogen); Jungle Key; ; Plantera | (@calamity & #Plantera); @@ -376,22 +493,25 @@ Desert Key; ; Big Booty; Achievement; Dungeon & Wall of Flesh & Plantera & (Evil Key | Frozen Key | Jungle Key | Hallowed Key | Desert Key); Rainbow Gun; ; Dungeon & Wall of Flesh & Plantera & Hallowed Key; Rainbows and Unicorns; Achievement; Blessed Apple & Rainbow Gun; -Perennial Ore; Calamity; Plantera; -Perennial Bar; Calamity; Hardmode Forge & Perennial Ore; +Perennial Ore; Calamity; Plantera | Perennial Bar; +Perennial Bar; Calamity; (Hardmode Forge & Perennial Ore) | Plantera | Life Alloy; Beastial Pickaxe; Calamity | Pickaxe(200); Hardmode Anvil & Perennial Bar; -Armored Digger; Calamity | Location | Item; Plantera; // TODO Check // Solar Eclipse Temple Raider; Achievement; #Plantera; Lihzahrd Temple; ; #Plantera | (Plantera & Actuator) | @pickaxe(210) | (@calamity & Hardmode Anvil & Soul of Light & Soul of Night); +Lihzahrd Furniture; ; Lihzahrd Temple; Solar Eclipse; ; Lihzahrd Temple & Wall of Flesh; -Broken Hero Sword; ; (Solar Eclipse & Plantera & @mech_boss(3)) | (@calamity & #Calamitas Clone); +Broken Hero Sword; ; Solar Eclipse & Plantera & @mech_boss(3); Terra Blade; ; Hardmode Anvil & True Night's Edge & True Excalibur & Broken Hero Sword & (~@calamity | Living Shard); Sword of the Hero; Achievement; Terra Blade; +Neptune's Shell; ; Solar Eclipse; Kill the Sun; Achievement; Solar Eclipse; // Great Sand Shark Great Sand Shark; Calamity | Location | Item; Hardmode Anvil & Forbidden Fragment & Core of Sunlight; +Grand Scale; Calamity; #Great Sand Shark | Sand Shark Tooth Necklace; +Sand Shark Tooth Necklace; Calamity; (Tinkerer's Workshop & Shark Tooth Necklace & Avenger Emblem & Grand Scale) | (@getfixedboi & #Desert Scourge); // Leviathan and Anahita Leviathan and Anahita; Calamity | Location | Item; @@ -404,32 +524,40 @@ Starbuster Core; Calamity | Minions(1); Golem; Location | Item | Goal; (Wall of Flesh & Plantera & Lihzahrd Temple) | (@calamity & Hardmode Anvil & Lihzahrd Temple & Essence of Sunlight); # golem Picksaw; Pickaxe(210); #Golem; -Lihzahrd Brick; ; @pickaxe(210); -Scoria Ore; Calamity; Golem | @pickaxe(210); -Scoria Bar; Calamity; Hardmode Forge & Scoria Ore; +Lihzahrd Brick; ; @pickaxe(210) | (Lihzahrd Furniture & Golem) | (@calamity & Lihzahrd Temple); +Scoria Ore; Calamity; Golem | @pickaxe(210) | Astral Ore | Luminite | Scoria Bar; +Scoria Bar; Calamity; (Hardmode Forge & Scoria Ore) | Golem | Life Alloy | Sigil of Calamitas | Fire Gauntlet; Seismic Hampick; Calamity | Pickaxe(210) | Hammer(95); Hardmode Anvil & Scoria Bar; -Life Alloy; Calamity; (Hardmode Anvil & Cryonic Bar & Perennial Bar & Scoria Bar) | Necromantic Geode; +Hydrothermic Armor; Calamity | Armor Minions(2); Hardmode Anvil & Scoria Bar & Core of Havoc; +Fire Gauntlet; ; (Tinkerer's Workshop & Magma Stone & Mechanical Glove & (~@calamity | Scoria Bar)) | (@calamity & Elemental Gauntlet); +Sigil of Calamitas; Calamity; (Hardmode Anvil & Celestial Emblem & Scoria Bar & Ashes of Calamity) | Ethereal Talisman; +Life Alloy; Calamity; (Hardmode Anvil & Cryonic Bar & Perennial Bar & Scoria Bar) | Necromantic Geode | Star-Tainted Generator | (@getfixedboi & #Yharon, Dragon of Rebirth); Advanced Display; Calamity; Hardmode Anvil & Mysterious Circuitry & Dubious Plating & Life Alloy & Long Ranged Sensor Array; +Star-Tainted Generator; Calamity; (Hardmode Anvil & Jelly-Charged Battery & Nuclear Fuel Rod & Starbuster Core & Life Alloy) | Nucleogenesis; Old One's Army Tier 3; Location | Item; #Old One's Army Tier 1 & Wall of Flesh & Golem; // Martian Madness Martian Madness; Location | Item; Wall of Flesh & Golem; +Laser Drill; Pickaxe(220); #Martian Madness; Influx Waver; ; #Martian Madness; // The Plaguebringer Goliath -Plague Cell Canister; Calamity; Golem; -Plaguebringer; Calamity | Location | Item; Golem; +Plague Cell Canister; Calamity; Golem | Alchemical Flask | (@getfixedboi & #Queen Bee); +Alchemical Flask; Calamity; (Hardmode Anvil & Bee Wax & Plague Cell Canister) | (@getfixedboi & #Queen Bee); The Plaguebringer Goliath; Calamity | Location | Item; Hardmode Anvil & Plague Cell Canister; +Infected Armor Plating; Calamity; #The Plaguebringer Goliath; +Plaguebringer Armor; Calamity | Armor Minions(3); Hardmode Anvil & Bee Armor & Alchemical Flask & Plague Cell Canister & Infected Armor Plating; // Duke Fishron -Duke Fishron; Location | Item; Bug Net & Wall of Flesh; +Duke Fishron; Location | Item; (Bug Net & Wall of Flesh) | (@getfixedboi & @calamity & #Astrum Deus); // Pumpkin Moon Pumpkin Moon; ; Hardmode Anvil & Pumpkin & Ectoplasm & (@calamity | Hallowed Bar); -Spooky Armor; ArmorMinions(4); Pumpkin Moon; +Spooky Armor; Armor Minions(4); Pumpkin Moon; Mourning Wood; Location | Item; Pumpkin Moon; Necromantic Scroll; Minions(1); #Mourning Wood; -Papyrus Scarab; Minions(1); Tinkerer's Workshop & Hercules Beetle & Necromantic Scroll; +Papyrus Scarab; Minions(1); (Tinkerer's Workshop & Hercules Beetle & Necromantic Scroll) | (@calamity & Statis' Blessing); +Statis' Blessing; Calamity; (Hardmode Anvil & Papyrus Scarab & Pygmy Necklace & Emblem & Holy Water & Core of Sunlight) | Statis' Curse; Pumpking; Location | Item; Pumpkin Moon; The Horseman's Blade; ; #Pumpking; Baleful Harvest; Achievement; Pumpkin Moon; @@ -451,11 +579,11 @@ Ravager; Calamity | Location | Item; Fleshy Geode; Calamity; #Ravager; // Empress of Light -Empress of Light; Location | Item | Goal; Wall of Flesh & Hallow & (@calamity | Plantera); +Empress of Light; Location | Item | Goal; (Wall of Flesh & Hallow & (@calamity | Plantera)) | (@getfixedboi & @calamity & #Supreme Alchemist, Cirrus); # empress_of_light // Lunatic Cultist -Lunatic Cultist; Location | Item | Goal; (@calamity | (Dungeon & Golem)) & Wall of Flesh; +Lunatic Cultist; Location | Item | Goal; ((@calamity | (Dungeon & Golem)) & Wall of Flesh) | (@calamity & Calamitas Clone); Astrum Deus; Calamity | Location | Item | Goal; Titan Heart; # lunatic_cultist # astrum_deus @@ -463,111 +591,142 @@ Ancient Manipulator; ; // Lunar Events Lunar Events; Location | Item; #Lunatic Cultist; -Fragment; ; #Lunar Events | #Astrum Deus; -Galactica Singularity; Calamity; Ancient Manipulator & Fragment; -Meld Blob; Calamity; #Lunar Events | #Astrum Deus; -Meld Construct; Calamity; Ancient Manipulator & Meld Blob & Stardust; +Fragment; ; #Lunar Events | #Astrum Deus | (Ancient Manipulator & (Nebula Fragment | Stardust Fragment)) | (@calamity & Galactica Singularity); +Nebula Fragment; ; Fragment | (@calamity & Eye of Magnus); +Eye of Magnus; Calamity; (Ancient Manipulator & Lunic Eye & Nebula Fragment) | (@getfixedboi & #Wall of Flesh); +Stardust Fragment; ; Fragment | (@calamity & Statis' Curse); +Statis' Curse; Calamity; (Ancient Manipulator & Statis' Blessing & The First Shadowflame & Stardust Fragment) | Nucleogenesis; +Galactica Singularity; Calamity; (Ancient Manipulator & Fragment) | Elemental Gauntlet | Elemental Quiver | Nucleogenesis | Moonstone Crown | Ethereal Talisman; +Meld Blob; Calamity; #Lunar Events | #Astrum Deus | (Astral Infection & Astrum Deus) | Meld Construct; +Meld Construct; Calamity; (Ancient Manipulator & Meld Blob & Stardust) | Dark Matter Sheath; +Dark Matter Sheath; Calamity; (Ancient Manipulator & Silencing Sheath & Ruin Medallion & Meld Construct) | Eclipse Mirror; // Astrum Deus -Astral Ore; Calamity; Wall of Flesh & Astrum Deus; -Astral Bar; Calamity; Ancient Manipulator & Stardust & Astral Ore; +Astral Ore; Calamity; (Astral Infection & Astrum Deus) | Astral Bar; // No pick needed; you can fish it +Astral Bar; Calamity; (Ancient Manipulator & Stardust & Astral Ore) | (Astral Infection & Astrum Deus); Astral Hamaxe; Calamity | Hammer(100); Ancient Manipulator & Astral Bar; Astral Pickaxe; Calamity | Pickaxe(220); Ancient Manipulator & Astral Bar; +Astral Armor; Calamity | Armor Minions(3); Ancient Manipulator & Astral Bar & Meteorite Bar; // Moon Lord Moon Lord; Location | Item | Goal; #Lunar Events; # moon_lord Slayer of Worlds; Achievement; #Evil Boss & #The Destroyer & #Duke Fishron & #Eye of Cthulhu & #Golem & #King Slime & #Lunatic Cultist & #Moon Lord & #Plantera & #Queen Bee & #Skeletron & #Skeletron Prime & #The Twins & #Wall of Flesh; -Luminite; ; #Moon Lord; -Luminite Bar; ; Ancient Manipulator & Luminite; +Luminite; ; #Moon Lord | (@calamity & (Exodium Cluster | Asteroid Staff)) | Luminite Bar; +Luminite Bar; ; (Ancient Manipulator & Luminite) | (@calamity & (Elemental Gauntlet | Elemental Quiver | Nucleogenesis | Moonstone Crown | Ethereal Talisman)); Luminite Hamaxe; Hammer(100); Ancient Manipulator & Fragment & Luminite Bar; Luminite Pickaxe; Pickaxe(225); Ancient Manipulator & Fragment & Luminite Bar; Genesis Pickaxe; Calamity | Pickaxe(225); Ancient Manipulator & Meld Construct & Luminite Bar; -Stardust Armor; ArmorMinions(5); Ancient Manipulator & Fragment & Luminite Bar; +Stardust Armor; Armor Minions(5); Ancient Manipulator & Fragment & Luminite Bar; +Asteroid Staff; Calamity; (Ancient Manipulator & Meteor Staff & Luminite Bar) | (@getfixedboi & #Astrum Aureus); +Moonstone Crown; Calamity; (Ancient Manipulator & Feather Crown & Luminite Bar & Galactica Singularity) | Nanotech; Terrarian; ; #Moon Lord; Sick Throw; Achievement; Terrarian; Meowmere; ; #Moon Lord; Star Wrath; ; #Moon Lord; -Exodium Cluster; Calamity; Moon Lord & @pickaxe(225); +Exodium Cluster; Calamity; Moon Lord | Uelibloom Ore; // No pick needed; can be fished Normality Relocator; Calamity; Ancient Manipulator & Rod of Discord & Exodium Cluster & Fragment; -Unholy Essence; Calamity; Moon Lord | #Providence, the Profaned Goddess; -Phantoplasm; Calamity; Moon Lord & (Wall of Flesh | Dungeon); // TODO Check -Eldritch Soul Artifact; Calamity; Exodium Cluster & Navyplate & Phantoplasm; +Unholy Essence; Calamity; Moon Lord | #Providence, the Profaned Goddess | (Hallow & Providence, the Profaned Goddess) | (@getfixedboi & Plantera); +Polterplasm; Calamity; (Moon Lord & Wall of Flesh) | (Dungeon & (Polterghast | Moon Lord)) | #Polterghast; +Eldritch Soul Artifact; Calamity | Minions(1); Exodium Cluster & Navyplate & Polterplasm; // Profaned Guardians Profaned Guardians; Calamity | Location | Item; Ancient Manipulator & Unholy Essence & Luminite Bar; // Dragonfolly -The Dragonfolly; Calamity | Location | Item; Ancient Manipulator & Unholy Essence & Luminite Bar; -Effulgent Feather; Calamity; Moon Lord | #The Dragonfolly; +The Dragonfolly; Calamity | Location | Item; (Ancient Manipulator & Unholy Essence & Luminite Bar) | (@getfixedboi & #Supreme Alchemist, Cirrus); +Effulgent Feather; Calamity; Moon Lord | #The Dragonfolly | (@getfixedboi & #Yharon, Dragon of Rebirth); // Providence, the Profaned Goddess -Providence, the Profaned Goddess; Calamity | Location | Item | Goal; #Profaned Guardians; +Providence, the Profaned Goddess; Calamity | Location | Item | Goal; #Profaned Guardians | (@getfixedboi & #Supreme Alchemist, Cirrus); # providence_the_profaned_goddess -Divine Geode; Calamity; #Providence, the Profaned Goddess; +Divine Geode; Calamity; #Providence, the Profaned Goddess | (@getfixedboi & #Profaned Guardians); Profaned Soul Artifact; Calamity | Minions(1); Exodium Cluster & Havocplate & Divine Geode; Rune of Kos; Calamity; #Providence, the Profaned Goddess; -Uelibloom Ore; Calamity; Providence, the Profaned Goddess; -Uelibloom Bar; Calamity; Hardmode Forge & Uelibloom Ore; +Uelibloom Ore; Calamity; Providence, the Profaned Goddess | Auric Ore | Uelibloom Bar; +Uelibloom Bar; Calamity; (Hardmode Forge & Uelibloom Ore) | Providence, the Profaned Goddess; Grax; Calamity | Hammer(110); Ancient Manipulator & Inferna Cutter & Luminite Hamaxe & Uelibloom Bar; Blossom Pickaxe; Calamity | Pickaxe(250); Ancient Manipulator & Uelibloom Bar; Voltage Regulation System; Calamity; Ancient Manipulator & Mysterious Circuitry & Dubious Plating & Uelibloom Bar & Luminite Bar & Advanced Display; +Tarragon Armor; Calamity | Armor Minions(3); Ancient Manipulator & Uelibloom Bar & Divine Geode; Necromantic Geode; Calamity; #Ravager & Providence, the Profaned Goddess; +Bloodstone; Calamity; Providence, the Profaned Goddess; +Bloodstone Core; Calamity; Hardmode Forge & Bloodstone & Polterplasm; // Sentinels of the Devourer Storm Weaver; Calamity | Location | Item; Rune of Kos; Armored Shell; Calamity; #Storm Weaver; -Ceaseless Void; Calamity | Location | Item; Rune of Kos; +Ceaseless Void; Calamity | Location | Item; Dungeon & Rune of Kos; Dark Plasma; Calamity; #Ceaseless Void; Signus, Envoy of the Devourer; Calamity | Location | Item; Rune of Kos; Twisting Nether; Calamity; #Signus, Envoy of the Devourer; // Polterghast -Polterghast; Calamity | Location | Item; Dungeon & ((Ancient Manipulator & Phantoplasm) | Moon Lord); -Colossal Squid; Calamity | Location | Item; -Reaper Shark; Calamity | Location | Item; -Eidolon Wyrm; Calamity | Location | Item; +Polterghast; Calamity | Location | Item; Dungeon & ((Ancient Manipulator & Polterplasm) | Moon Lord); +Ruinous Soul; Calamity; #Polterghast; +Bloodflare Armor; Calamity | Armor Minions(3); Ancient Manipulator & Bloodstone Core & Ruinous Soul; +Reaper Tooth; Calamity; Polterghast; +Omega Blue Armor; Calamity | Armor Minions(2); Ancient Manipulator & Reaper Tooth & Depth Cells & Ruinous Soul; // The Old Duke -Mauler; Calamity | Location | Item; #Acid Rain Tier 3; -Nuclear Terror; Calamity | Location | Item; #Acid Rain Tier 3; -Acid Rain Tier 3; Calamity | Location | Item; #Acid Rain Tier 1 & Polterghast; // TODO Check -The Old Duke; Calamity | Location | Item; #Acid Rain Tier 3 | (Bug Net & Moon Lord) | (Amidias & The Old Duke); +Mauler; Calamity | Location | Item; Acid Rain Tier 3; +Nuclear Terror; Calamity | Location | Item; Acid Rain Tier 3; +Acid Rain Tier 3; Calamity; #Acid Rain Tier 1 & Polterghast; +The Old Duke; Calamity | Location | Item; Acid Rain Tier 3 | (Bug Net & Moon Lord) | (Amidias & The Old Duke) | (@getfixedboi & #The Destroyer); // The Devourer of Gods -The Devourer of Gods; Calamity | Location | Item | Goal; Ancient Manipulator & ((Armored Shell & Twisting Nether & Dark Plasma) | (Luminite Bar & Galactica Singularity & Phantoplasm)); +The Devourer of Gods; Calamity | Location | Item | Goal; (Ancient Manipulator & ((Armored Shell & Twisting Nether & Dark Plasma) | (Luminite Bar & Galactica Singularity & Polterplasm))) | (@getfixedboi & #Supreme Alchemist, Cirrus); # the_devourer_of_gods Cosmilite Bar; Calamity; #The Devourer of Gods; Cosmic Anvil; Calamity; Ancient Manipulator & Hardmode Anvil & Cosmilite Bar & Luminite Bar & Galactica Singularity & Exodium Cluster; -Nightmare Fuel; Calamity; Pumpkin Moon & The Devourer of Gods; +Nightmare Fuel; Calamity; (Pumpkin Moon & The Devourer of Gods) | Occult Skull Crown; +Occult Skull Crown; Calamity | Getfixedboi; @getfixedboi & #Evil Boss; // Revengeance or getfixedboi Endothermic Energy; Calamity; Frost Moon & The Devourer of Gods; -Darksun Fragment; Calamity; Solar Eclipse & The Devourer of Gods; +Darksun Fragment; Calamity; (Solar Eclipse & The Devourer of Gods) | Eclipse Mirror; Dark Sun Ring; Calamity; Cosmic Anvil & Uelibloom Bar & Darksun Fragment; -Ascendant Spirit Essence; Calamity; Ancient Manipulator & Phantoplasm & Nightmare Fuel & Endothermic Energy & Darksun Fragment; +Eclipse Mirror; Calamity; (Cosmic Anvil & Abyssal Mirror & Dark Matter Sheath & Darksun Fragment) | (@getfixedboi & #Ceaseless Void); +Ascendant Spirit Essence; Calamity; (Ancient Manipulator & Polterplasm & Nightmare Fuel & Endothermic Energy & Darksun Fragment) | (@getfixedboi & #Providence, the Profaned Goddess) | Elemental Gauntlet | Elemental Quiver | Nucleogenesis | Nanotech | Ethereal Talisman; +Fearmonger Armor; Calamity | Armor Minions(2); Cosmic Anvil & Spooky Armor & Cosmilite Bar & Soul of Fright & Ascendant Spirit Essence; +Silva Armor; Calamity | Armor Minions(5); Cosmic Anvil & Effulgent Feather & Ascendant Spirit Essence; +Elemental Gauntlet; Calamity; (Cosmic Anvil & Fire Gauntlet & Luminite Bar & Galactica Singularity & Ascendant Spirit Essence) | (@getfixedboi & #Storm Weaver); +Elemental Quiver; Calamity; (Cosmic Anvil & Magic Quiver & Deadshot Brooch & Luminite Bar & Galactica Singularity & Ascendant Spirit Essence) | (@getfixedboi & #Storm Weaver); +Nucleogenesis; Calamity; (Cosmic Anvil & Star-Tainted Generator & Statis' Curse & Luminite Bar & Galactica Singularity & Ascendant Spirit Essence) | (@getfixedboi & #Ceaseless Void); +Nanotech; Calamity; (Emblem & Raider's Talisman & Moonstone Crown & Electrician's Glove & Luminite Bar & Galactica Singularity & Ascendant Spirit Essence) | (@getfixedboi & #Signus, Envoy of the Devourer); +Ethereal Talisman; Calamity; (Cosmic Anvil & Sigil of Calamitas & Mana Flower & Luminite Bar & Galactica Singularity & Ascendant Spirit Essence) | (@getfixedboi & #Signus, Envoy of the Devourer); // Yharon, Dragon of Rebirth Yharon, Dragon of Rebirth; Calamity | Location | Item | Goal; Ancient Manipulator & Effulgent Feather & Life Alloy; # yharon_dragon_of_rebirth -Yharon Soul Fragment; Calamity; #Yharon, Dragon of Rebirth; -Auric Ore; Calamity; Yharon, Dragon of Rebirth & @pickaxe(250); -Auric Bar; Calamity; Cosmic Anvil & Auric Ore & Yharon Soul Fragment; -Zenith; Location | Item(Has Zenith) | Goal; Hardmode Anvil & Terra Blade & Meowmere & Star Wrath & Influx Waver & The Horseman's Blade & Seedler & Starfury & Bee Keeper & Enchanted Sword & Copper Shortsword & (~@calamity | Auric Bar); +Yharon Soul Fragment; Calamity; #Yharon, Dragon of Rebirth | The Wand | Auric Bar; +The Wand; Calamity; (Cosmic Anvil & Wand of Sparking & Yharon Soul Fragment) | (@getfixedboi & #The Devourer of Gods); +Auric Ore; Calamity; (Yharon, Dragon of Rebirth & (@pickaxe(250) | Wall of Flesh)) | Auric Bar; +Auric Bar; Calamity; (Cosmic Anvil & Auric Ore & Yharon Soul Fragment) | Shadowspec Bar; +Auric Tesla Armor; Calamity | Armor Minions(6); Cosmic Anvil & Silva Armor & Bloodflare Armor & Tarragon Armor & Auric Bar; +Infinity +1 Sword; Achievement | Grindy | Item(Has Zenith) | Goal(zenith); Hardmode Anvil & Terra Blade & Meowmere & Star Wrath & Influx Waver & The Horseman's Blade & Seedler & Starfury & Bee Keeper & Enchanted Sword & Copper Shortsword & (~@calamity | Auric Bar); # zenith // Exo Mechs Auric Quantum Cooling Cell; Calamity; Cosmic Anvil & Auric Bar & Mysterious Circuitry & Dubious Plating & Endothermic Energy & Core of Eleum & Voltage Regulation System; Exo Mechs; Calamity | Location | Item | Final Boss; Codebreaker Base & Decryption Computer & Auric Quantum Cooling Cell; -Supreme Witch, Calamitas; Calamity | Location | Item | Final Boss; Cosmic Anvil & Brimstone Slag & Auric Bar & Core of Calamity & Ashes of Calamity; +// XB-Infinity Hekate; Getfixedboi | Calamity; Codebreaker Base & Decryption Computer & Auric Quantum Cooling Cell & Blood Sample; // Currently, this boss doesn't affect logic at all +Supreme Witch, Calamitas; Calamity | Location | Item | Final Boss; (Cosmic Anvil & Brimstone Slag & Auric Bar & Core of Calamity & Ashes of Calamity) | (@getfixedboi & #Supreme Alchemist, Cirrus); +Supreme Alchemist, Cirrus; Getfixedboi | Calamity; Cosmic Anvil & Brimstone Slag & Auric Bar & Core of Calamity & Fabsol's Vodka; +THE LORDE; Getfixedboi | Calamity; Lihzahrd Temple; # calamity_final_bosses -Exo Prism; Calamity; #Exo Mechs; +Exo Prism; Calamity; #Exo Mechs | Shadowspec Bar; Draedon's Forge; Calamity; Cosmic Anvil & Hardmode Forge & Tinkerer's Workshop & Ancient Manipulator & Auric Bar & Exo Prism & Ascendant Spirit Essence; // Supreme Witch, Calamitas -Ashes of Annihilation; Calamity; #Supreme Witch, Calamitas; +Ashes of Annihilation; Calamity; #Supreme Witch, Calamitas | (@getfixedboi & #Calamitas Clone) | Shadowspec Bar; Shadowspec Bar; Calamity; Draedon's Forge & Auric Bar & Exo Prism & Ashes of Annihilation; Crystyl Crusher; Calamity | Pickaxe(1000); Draedon's Forge & Luminite Pickaxe & Blossom Pickaxe & Shadowspec Bar; Angelic Alliance; Calamity | Minions(2); Draedon's Forge & Hallowed Armor & Paladin's Shield & True Excalibur & Cross Necklace & Shadowspec Bar; +Demonshade Armor; Calamity | Armor Minions(10); Draedon's Forge & Shadowspec Bar; -// Adult Eidolon Wyrm; -Adult Eidolon Wyrm; Calamity | Location | Item | Goal; Rod of Discord | Normality Relocator; -# adult_eidolon_wyrm +// Primordial Wyrm +Primordial Wyrm; Calamity | Location | Item | Goal; Rod of Discord | Normality Relocator; +# primordial_wyrm + +// Boss Rush +Boss Rush; Calamity | Location | Item | Goal; Diving Gear | Neptune's Shell | (Aquatic Heart & Skeletron); // Might be obtainable earlier with Midas' Blessing +# boss_rush diff --git a/worlds/terraria/__init__.py b/worlds/terraria/__init__.py index abc10a7bb3..20f56c8f63 100644 --- a/worlds/terraria/__init__.py +++ b/worlds/terraria/__init__.py @@ -1,11 +1,13 @@ # Look at `Rules.dsv` first to get an idea for how this works +import logging from typing import Union, Tuple, List, Dict, Set from worlds.AutoWorld import WebWorld, World from BaseClasses import Region, ItemClassification, Tutorial, CollectionState from .Checks import ( TerrariaItem, TerrariaLocation, + Condition, goals, rules, rule_indices, @@ -25,7 +27,7 @@ from .Checks import ( armor_minions, accessory_minions, ) -from .Options import TerrariaOptions +from .Options import TerrariaOptions, Goal class TerrariaWeb(WebWorld): @@ -55,8 +57,8 @@ class TerrariaWorld(World): item_name_to_id = item_name_to_id location_name_to_id = location_name_to_id - # Turn into an option when calamity is supported in the mod calamity = False + getfixedboi = False ter_items: List[str] ter_locations: List[str] @@ -70,72 +72,100 @@ class TerrariaWorld(World): ter_goals = {} goal_items = set() for location in goal_locations: - _, flags, _, _ = rules[rule_indices[location]] + flags = rules[rule_indices[location]].flags + if not self.options.calamity.value and "Calamity" in flags: + logging.warning( + f"Terraria goal `{Goal.name_lookup[self.options.goal.value]}`, which requires Calamity, was selected with Calamity disabled; enabling Calamity" + ) + self.options.calamity.value = True + item = flags.get("Item") or f"Post-{location}" ter_goals[item] = location goal_items.add(item) - achievements = self.options.achievements.value location_count = 0 locations = [] - for rule, flags, _, _ in rules[:goal]: - if ( - (not self.calamity and "Calamity" in flags) - or (achievements < 1 and "Achievement" in flags) - or (achievements < 2 and "Grindy" in flags) - or (achievements < 3 and "Fishing" in flags) - or ( - rule == "Zenith" and self.options.goal.value != 11 - ) # Bad hardcoding - ): - continue - if "Location" in flags or ("Achievement" in flags and achievements >= 1): - # Location - location_count += 1 - locations.append(rule) - elif ( - "Achievement" not in flags - and "Location" not in flags - and "Item" not in flags - ): - # Event - locations.append(rule) - item_count = 0 items = [] - for rule, flags, _, _ in rules[:goal]: - if not self.calamity and "Calamity" in flags: + for rule in rules[:goal]: + early = "Early" in rule.flags + grindy = "Grindy" in rule.flags + fishing = "Fishing" in rule.flags + + if ( + (not self.options.getfixedboi.value and "Getfixedboi" in rule.flags) + or (self.options.getfixedboi.value and "Not Getfixedboi" in rule.flags) + or (not self.options.calamity.value and "Calamity" in rule.flags) + or (self.options.calamity.value and "Not Calamity" in rule.flags) + or ( + self.options.getfixedboi.value + and self.options.calamity.value + and "Not Calamity Getfixedboi" in rule.flags + ) + or (not self.options.early_achievements.value and early) + or ( + not self.options.normal_achievements.value + and "Achievement" in rule.flags + and not early + and not grindy + and not fishing + ) + or (not self.options.grindy_achievements.value and grindy) + or (not self.options.fishing_achievements.value and fishing) + ) and rule.name not in goal_locations: continue - if "Item" in flags: - # Item - item_count += 1 - if rule not in goal_locations: - items.append(rule) + + if "Location" in rule.flags or "Achievement" in rule.flags: + # Location + location_count += 1 + locations.append(rule.name) elif ( - "Achievement" not in flags - and "Location" not in flags - and "Item" not in flags + "Achievement" not in rule.flags + and "Location" not in rule.flags + and "Item" not in rule.flags ): # Event - items.append(rule) + locations.append(rule.name) + + if "Item" in rule.flags and not ( + "Achievement" in rule.flags and rule.name not in goal_locations + ): + # Item + item_count += 1 + if rule.name not in goal_locations: + items.append(rule.name) + elif ( + "Achievement" not in rule.flags + and "Location" not in rule.flags + and "Item" not in rule.flags + ): + # Event + items.append(rule.name) - extra_checks = self.options.fill_extra_checks_with.value ordered_rewards = [ reward for reward in labels["ordered"] - if self.calamity or "Calamity" not in rewards[reward] + if self.options.calamity.value or "Calamity" not in rewards[reward] ] - while extra_checks == 1 and item_count < location_count and ordered_rewards: + while ( + self.options.fill_extra_checks_with.value == 1 + and item_count < location_count + and ordered_rewards + ): items.append(ordered_rewards.pop(0)) item_count += 1 random_rewards = [ reward for reward in labels["random"] - if self.calamity or "Calamity" not in rewards[reward] + if self.options.calamity.value or "Calamity" not in rewards[reward] ] self.multiworld.random.shuffle(random_rewards) - while extra_checks == 1 and item_count < location_count and random_rewards: + while ( + self.options.fill_extra_checks_with.value == 1 + and item_count < location_count + and random_rewards + ): items.append(random_rewards.pop(0)) item_count += 1 @@ -173,9 +203,9 @@ class TerrariaWorld(World): def create_items(self) -> None: for item in self.ter_items: if (rule_index := rule_indices.get(item)) is not None: - _, flags, _, _ = rules[rule_index] - if "Item" in flags: - name = flags.get("Item") or f"Post-{item}" + rule = rules[rule_index] + if "Item" in rule.flags: + name = rule.flags.get("Item") or f"Post-{item}" else: continue else: @@ -186,8 +216,8 @@ class TerrariaWorld(World): locked_items = {} for location in self.ter_locations: - _, flags, _, _ = rules[rule_indices[location]] - if "Location" not in flags and "Achievement" not in flags: + rule = rules[rule_indices[location]] + if "Location" not in rule.flags and "Achievement" not in rule.flags: if location in progression: classification = ItemClassification.progression else: @@ -202,95 +232,92 @@ class TerrariaWorld(World): for location, item in locked_items.items(): self.multiworld.get_location(location, self.player).place_locked_item(item) - def check_condition( - self, - state, - sign: bool, - ty: int, - condition: Union[str, Tuple[Union[bool, None], list]], - arg: Union[str, int, None], - ) -> bool: - if ty == COND_ITEM: - _, flags, _, _ = rules[rule_indices[condition]] - if "Item" in flags: - name = flags.get("Item") or f"Post-{condition}" + def check_condition(self, state, condition: Condition) -> bool: + if condition.type == COND_ITEM: + rule = rules[rule_indices[condition.condition]] + if "Item" in rule.flags: + name = rule.flags.get("Item") or f"Post-{condition.condition}" else: - name = condition + name = condition.condition - return sign == state.has(name, self.player) - elif ty == COND_LOC: - _, _, operator, conditions = rules[rule_indices[condition]] - return sign == self.check_conditions(state, operator, conditions) - elif ty == COND_FN: - if condition == "npc": - if type(arg) is not int: + return condition.sign == state.has(name, self.player) + elif condition.type == COND_LOC: + rule = rules[rule_indices[condition.condition]] + return condition.sign == self.check_conditions( + state, rule.operator, rule.conditions + ) + elif condition.type == COND_FN: + if condition.condition == "npc": + if type(condition.argument) is not int: raise Exception("@npc requires an integer argument") npc_count = 0 for npc in npcs: if state.has(npc, self.player): npc_count += 1 - if npc_count >= arg: - return sign + if npc_count >= condition.argument: + return condition.sign - return not sign - elif condition == "calamity": - return sign == self.calamity - elif condition == "grindy": - return sign == (self.options.achievements.value >= 2) - elif condition == "pickaxe": - if type(arg) is not int: + return not condition.sign + elif condition.condition == "calamity": + return condition.sign == self.options.calamity.value + elif condition.condition == "grindy": + return condition.sign == self.options.grindy_achievements.value + elif condition.condition == "pickaxe": + if type(condition.argument) is not int: raise Exception("@pickaxe requires an integer argument") for pickaxe, power in pickaxes.items(): - if power >= arg and state.has(pickaxe, self.player): - return sign + if power >= condition.argument and state.has(pickaxe, self.player): + return condition.sign - return not sign - elif condition == "hammer": - if type(arg) is not int: + return not condition.sign + elif condition.condition == "hammer": + if type(condition.argument) is not int: raise Exception("@hammer requires an integer argument") for hammer, power in hammers.items(): - if power >= arg and state.has(hammer, self.player): - return sign + if power >= condition.argument and state.has(hammer, self.player): + return condition.sign - return not sign - elif condition == "mech_boss": - if type(arg) is not int: + return not condition.sign + elif condition.condition == "mech_boss": + if type(condition.argument) is not int: raise Exception("@mech_boss requires an integer argument") boss_count = 0 for boss in mech_bosses: if state.has(boss, self.player): boss_count += 1 - if boss_count >= arg: - return sign + if boss_count >= condition.argument: + return condition.sign - return not sign - elif condition == "minions": - if type(arg) is not int: + return not condition.sign + elif condition.condition == "minions": + if type(condition.argument) is not int: raise Exception("@minions requires an integer argument") minion_count = 1 for armor, minions in armor_minions.items(): if state.has(armor, self.player) and minions + 1 > minion_count: minion_count = minions + 1 - if minion_count >= arg: - return sign + if minion_count >= condition.argument: + return condition.sign for accessory, minions in accessory_minions.items(): if state.has(accessory, self.player): minion_count += minions - if minion_count >= arg: - return sign + if minion_count >= condition.argument: + return condition.sign - return not sign + return not condition.sign + elif condition.condition == "getfixedboi": + return condition.sign == self.options.getfixedboi.value else: - raise Exception(f"Unknown function {condition}") - elif ty == COND_GROUP: - operator, conditions = condition - return sign == self.check_conditions(state, operator, conditions) + raise Exception(f"Unknown function {condition.condition}") + elif condition.type == COND_GROUP: + operator, conditions = condition.condition + return condition.sign == self.check_conditions(state, operator, conditions) def check_conditions( self, @@ -310,22 +337,22 @@ class TerrariaWorld(World): return True if len(conditions) > 1: raise Exception("Found multiple conditions without an operator") - return self.check_condition(state, *conditions[0]) + return self.check_condition(state, conditions[0]) elif operator: return any( - self.check_condition(state, *condition) for condition in conditions + self.check_condition(state, condition) for condition in conditions ) else: return all( - self.check_condition(state, *condition) for condition in conditions + self.check_condition(state, condition) for condition in conditions ) def set_rules(self) -> None: for location in self.ter_locations: def check(state: CollectionState, location=location): - _, _, operator, conditions = rules[rule_indices[location]] - return self.check_conditions(state, operator, conditions) + rule = rules[rule_indices[location]] + return self.check_conditions(state, rule.operator, rule.conditions) self.multiworld.get_location(location, self.player).access_rule = check @@ -336,6 +363,12 @@ class TerrariaWorld(World): def fill_slot_data(self) -> Dict[str, object]: return { "goal": list(self.goal_locations), - "achievements": self.options.achievements.value, "deathlink": bool(self.options.death_link), + # The rest of these are included for trackers + "calamity": self.options.calamity.value, + "getfixedboi": self.options.getfixedboi.value, + "early_achievements": self.options.early_achievements.value, + "normal_achievements": self.options.normal_achievements.value, + "grindy_achievements": self.options.grindy_achievements.value, + "fishing_achievements": self.options.fishing_achievements.value, } From 125bf6f2702d9e7e8348c58dea639125a7778213 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:09:27 -0500 Subject: [PATCH 36/39] Core: Post-KivyMD cleanup 2 and enhancements (#4876) * Adds a new class allowing TextFields to be resized * Resizes most CommonClient components to be more in-line with pre-KivyMD * Change the color of SelectableLabels and TooltipLabels to white * Fixed ClientTabs not correctly showing the current tab indicator * The server label now features a (i) icon to indicate that it can be hovered over. * Changed the default `primary_palette` to `Lightsteelblue` and the default `dynamic_scheme_name` to `VIBRANT` * Properly set attributes on `KivyJSONToTextParser.TextColors` so that proper typing can be utilized if an individual value is needed * Fixed some buttons being discolored permanently once pressed * Sped up the animations of button ripples and tab switching * Added the ability to insert a new tab to `GameManager.add_client_tab` * Hovering over the "Command" button in CommonClient will now display the contents of `/help` as a popup (note: this popup can be too large on default height for adequately large /help (SC2 Client), but should always fit fine on fullscreen). * Fixed invalid sizing of MessageBox errors, and changed their text color to white --- Launcher.py | 5 ++ data/client.kv | 41 ++++++++-- data/launcher.kv | 7 +- kvui.py | 197 ++++++++++++++++++++++++++++++++++++----------- 4 files changed, 198 insertions(+), 52 deletions(-) diff --git a/Launcher.py b/Launcher.py index d636ceab74..29bd71764e 100644 --- a/Launcher.py +++ b/Launcher.py @@ -359,6 +359,11 @@ def run_gui(path: str, args: Any) -> None: self._refresh_components(self.current_filter) + # Uncomment to re-enable the Kivy console/live editor + # Ctrl-E to enable it, make sure numlock/capslock is disabled + # from kivy.modules.console import create_console + # create_console(Window, self.top_screen) + return self.top_screen def on_start(self): diff --git a/data/client.kv b/data/client.kv index ac0a45023c..562986cd17 100644 --- a/data/client.kv +++ b/data/client.kv @@ -16,21 +16,30 @@ orange: "FF7700" # Used for command echo # KivyMD theming parameters theme_style: "Dark" # Light/Dark - primary_palette: "Green" # Many options - dynamic_scheme_name: "TONAL_SPOT" + primary_palette: "Lightsteelblue" # Many options + dynamic_scheme_name: "VIBRANT" dynamic_scheme_contrast: 0.0 : color: self.theme_cls.primaryColor +: + ripple_color: app.theme_cls.primaryColor + ripple_duration_in_fast: 0.2 +: + ripple_color: app.theme_cls.primaryColor + ripple_duration_in_fast: 0.2 : adaptive_height: True - font_size: dp(20) + theme_font_size: "Custom" + font_size: "20dp" markup: True halign: "left" : size_hint: 1, None + theme_text_color: "Custom" + text_color: 1, 1, 1, 1 canvas.before: Color: - rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerLowColor + rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor Rectangle: size: self.size pos: self.pos @@ -154,9 +163,12 @@ : size: self.texture_size size_hint: None, None + theme_font_size: "Custom" font_size: dp(18) pos_hint: {'center_y': 0.5, 'center_x': 0.5} halign: "left" + theme_text_color: "Custom" + text_color: (1, 1, 1, 1) canvas.before: Color: rgba: 0.2, 0.2, 0.2, 1 @@ -175,11 +187,28 @@ rectangle: self.x-2, self.y-2, self.width+4, self.height+4 : pos_hint: {'center_y': 0.5, 'center_x': 0.5} - +: size_hint_y: None - height: dp(30) + height: "30dp" multiline: False write_tab: False + pos_hint: {"center_x": 0.5, "center_y": 0.5} +: + height: "30dp" + multiline: False + write_tab: False + role: "medium" + size_hint_y: None + pos_hint: {"center_x": 0.5, "center_y": 0.5} +: + size_hint_y: None + height: "30dp" + multiline: False + write_tab: False + pos_hint: {"center_x": 0.5, "center_y": 0.5} +: + theme_text_color: "Custom" + text_color: 1, 1, 1, 1 : layout: layout bar_width: "12dp" diff --git a/data/launcher.kv b/data/launcher.kv index 03e1c3e078..8c6a8288e4 100644 --- a/data/launcher.kv +++ b/data/launcher.kv @@ -5,12 +5,13 @@ size_hint: 1, None height: "75dp" context_button: context + focus_behavior: False MDRelativeLayout: ApAsyncImage: source: main.image size: (48, 48) - size_hint_y: None + size_hint: None, None pos_hint: {"center_x": 0.1, "center_y": 0.5} MDLabel: @@ -37,6 +38,7 @@ pos_hint:{"center_x": 0.85, "center_y": 0.8} theme_text_color: "Custom" text_color: app.theme_cls.primaryColor + detect_visible: False on_release: app.set_favorite(self) MDIconButton: @@ -46,6 +48,7 @@ pos_hint:{"center_x": 0.95, "center_y": 0.8} theme_text_color: "Custom" text_color: app.theme_cls.primaryColor + detect_visible: False MDButton: pos_hint:{"center_x": 0.9, "center_y": 0.25} @@ -53,7 +56,7 @@ height: "25dp" component: main.component on_release: app.component_action(self) - + detect_visible: False MDButtonText: text: "Open" diff --git a/kvui.py b/kvui.py index 81e3876fe5..9a8b7109fa 100644 --- a/kvui.py +++ b/kvui.py @@ -43,8 +43,8 @@ from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData from kivy.base import ExceptionHandler, ExceptionManager from kivy.clock import Clock from kivy.factory import Factory -from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty -from kivy.metrics import dp +from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty, StringProperty +from kivy.metrics import dp, sp from kivy.uix.widget import Widget from kivy.uix.layout import Layout from kivy.utils import escape_markup @@ -60,7 +60,7 @@ from kivymd.app import MDApp from kivymd.uix.gridlayout import MDGridLayout from kivymd.uix.floatlayout import MDFloatLayout from kivymd.uix.boxlayout import MDBoxLayout -from kivymd.uix.tab.tab import MDTabsPrimary, MDTabsItem, MDTabsItemText, MDTabsCarousel +from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.menu.menu import MDDropdownTextItem from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText @@ -90,10 +90,10 @@ remove_between_brackets = re.compile(r"\[.*?]") class ThemedApp(MDApp): def set_colors(self): text_colors = KivyJSONtoTextParser.TextColors() - self.theme_cls.theme_style = getattr(text_colors, "theme_style", "Dark") - self.theme_cls.primary_palette = getattr(text_colors, "primary_palette", "Green") - self.theme_cls.dynamic_scheme_name = getattr(text_colors, "dynamic_scheme_name", "TONAL_SPOT") - self.theme_cls.dynamic_scheme_contrast = getattr(text_colors, "dynamic_scheme_contrast", 0.0) + self.theme_cls.theme_style = text_colors.theme_style + self.theme_cls.primary_palette = text_colors.primary_palette + self.theme_cls.dynamic_scheme_name = text_colors.dynamic_scheme_name + self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast class ImageIcon(MDButtonIcon, AsyncImage): @@ -166,6 +166,32 @@ class ToggleButton(MDButton, ToggleButtonBehavior): child.icon_color = self.theme_cls.primaryColor +# thanks kivymd +class ResizableTextField(MDTextField): + """ + Resizable MDTextField that manually overrides the builtin sizing. + + Note that in order to use this, the sizing must be specified from within a .kv rule. + """ + def __init__(self, *args, **kwargs): + # cursed rules override + rules = Builder.match(self) + textfield = next((rule for rule in rules if rule.name == f""), None) + if textfield: + subclasses = rules[rules.index(textfield) + 1:] + for subclass in subclasses: + height_rule = subclass.properties.get("height", None) + if height_rule: + height_rule.ignore_prev = True + super().__init__(args, kwargs) + + +def on_release(self: MDButton, *args): + super(MDButton, self).on_release(args) + self.on_leave() + +MDButton.on_release = on_release + # I was surprised to find this didn't already exist in kivy :( class HoverBehavior(object): """originally from https://stackoverflow.com/a/605348110""" @@ -266,11 +292,15 @@ class TooltipLabel(HovererableLabel, MDTooltip): self._tooltip = None -class ServerLabel(HovererableLabel, MDTooltip): +class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout): tooltip_display_delay = 0.1 + text: str = StringProperty("Server:") def __init__(self, *args, **kwargs): - super(HovererableLabel, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + self.add_widget(MDIcon(icon="information", font_size=sp(15))) + self.add_widget(TooltipLabel(text=self.text, pos_hint={"center_x": 0.5, "center_y": 0.5}, + font_size=sp(15))) self._tooltip = ServerToolTip(text="Test") def on_enter(self): @@ -383,7 +413,6 @@ class MarkupDropdownTextItem(MDDropdownTextItem): for child in self.children: if child.__class__ == MDLabel: child.markup = True - print(self.text) # Currently, this only lets us do markup on text that does not have any icons # Create new TextItems as needed @@ -461,14 +490,13 @@ class MarkupDropdown(MDDropdownMenu): self.menu.data = self._items -class AutocompleteHintInput(MDTextField): +class AutocompleteHintInput(ResizableTextField): min_chars = NumericProperty(3) def __init__(self, **kwargs): super().__init__(**kwargs) - self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(24), width=self.width) - self.dropdown.bind(on_select=lambda instance, x: setattr(self, 'text', x)) + self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(2), width=self.width) self.bind(on_text_validate=self.on_message) self.bind(width=lambda instance, x: setattr(self.dropdown, "width", x)) @@ -485,8 +513,11 @@ class AutocompleteHintInput(MDTextField): def on_press(text): split_text = MarkupLabel(text=text).markup - return self.dropdown.select("".join(text_frag for text_frag in split_text - if not text_frag.startswith("["))) + self.set_text(self, "".join(text_frag for text_frag in split_text + if not text_frag.startswith("["))) + self.dropdown.dismiss() + self.focus = True + lowered = value.lower() for item_name in item_names: try: @@ -498,7 +529,7 @@ class AutocompleteHintInput(MDTextField): text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):] self.dropdown.items.append({ "text": text, - "on_release": lambda: on_press(text), + "on_release": lambda txt=text: on_press(txt), "markup": True }) if not self.dropdown.parent: @@ -620,7 +651,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout): self.selected = is_selected -class ConnectBarTextInput(MDTextField): +class ConnectBarTextInput(ResizableTextField): def insert_text(self, substring, from_undo=False): s = substring.replace("\n", "").replace("\r", "") return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo) @@ -630,14 +661,14 @@ def is_command_input(string: str) -> bool: return len(string) > 0 and string[0] in "/!" -class CommandPromptTextInput(MDTextField): +class CommandPromptTextInput(ResizableTextField): MAXIMUM_HISTORY_MESSAGES = 50 def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._command_history_index = -1 self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES) - + def update_history(self, new_entry: str) -> None: self._command_history_index = -1 if is_command_input(new_entry): @@ -664,7 +695,7 @@ class CommandPromptTextInput(MDTextField): self._change_to_history_text_if_available(self._command_history_index - 1) return True return super().keyboard_on_key_down(window, keycode, text, modifiers) - + def _change_to_history_text_if_available(self, new_index: int) -> None: if new_index < -1: return @@ -682,29 +713,61 @@ class MessageBox(Popup): def __init__(self, **kwargs): super().__init__(**kwargs) self._label.refresh() - self.size = self._label.texture.size - if self.width + 50 > Window.width: - self.text_size[0] = Window.width - 50 - self._label.refresh() - self.size = self._label.texture.size def __init__(self, title, text, error=False, **kwargs): label = MessageBox.MessageBoxLabel(text=text) separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.] - super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40), + super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40), separator_color=separator_color, **kwargs) self.height += max(0, label.height - 18) -class ClientTabs(MDTabsPrimary): +class ClientTabs(MDTabsSecondary): carousel: MDTabsCarousel lock_swiping = True def __init__(self, *args, **kwargs): - self.carousel = MDTabsCarousel(lock_swiping=True) - super().__init__(*args, MDDivider(size_hint_y=None, height=dp(4)), self.carousel, **kwargs) + self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2) + super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs) self.size_hint_y = 1 + def _check_panel_height(self, *args): + self.ids.tab_scroll.height = dp(38) + + def update_indicator( + self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None + ) -> None: + def update_indicator(*args): + indicator_pos = (0, 0) + indicator_size = (0, 0) + + item_text_object = self._get_tab_item_text_icon_object() + + if item_text_object: + indicator_pos = ( + instance.x + dp(12), + self.indicator.pos[1] + if not self._tabs_carousel + else self._tabs_carousel.height, + ) + indicator_size = ( + instance.width - dp(24), + self.indicator_height, + ) + + Animation( + pos=indicator_pos, + size=indicator_size, + d=0 if not self.indicator_anim else self.indicator_duration, + t=self.indicator_transition, + ).start(self.indicator) + + if not instance: + self.indicator.pos = (x, self.indicator.pos[1]) + self.indicator.size = (w, self.indicator_height) + else: + Clock.schedule_once(update_indicator) + def remove_tab(self, tab, content=None): if content is None: content = tab.content @@ -713,6 +776,21 @@ class ClientTabs(MDTabsPrimary): self.on_size(self, self.size) +class CommandButton(MDButton, MDTooltip): + def __init__(self, *args, manager: "GameManager", **kwargs): + super().__init__(*args, **kwargs) + self.manager = manager + self._tooltip = ToolTip(text="Test") + + def on_enter(self): + self._tooltip.text = self.manager.commandprocessor.get_help_text() + self._tooltip.font_size = dp(20 - (len(self._tooltip.text) // 400)) # mostly guessing on the numbers here + self.display_tooltip() + + def on_leave(self): + self.animation_tooltip_dismiss() + + class GameManager(ThemedApp): logging_pairs = [ ("Client", "Archipelago"), @@ -767,19 +845,19 @@ class GameManager(ThemedApp): self.grid = MainLayout() self.grid.cols = 1 - self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(70), + self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10)) # top part - server_label = ServerLabel(halign="center") + server_label = ServerLabel(width=dp(75)) self.connect_layout.add_widget(server_label) self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", - size_hint_y=None, role="medium", - height=dp(70), multiline=False, write_tab=False) + pos_hint={"center_x": 0.5, "center_y": 0.5}) def connect_bar_validate(sender): if not self.ctx.server: self.connect_button_action(sender) + self.server_connect_bar.height = dp(30) self.server_connect_bar.bind(on_text_validate=connect_bar_validate) self.connect_layout.add_widget(self.server_connect_bar) self.server_connect_button = MDButton(MDButtonText(text="Connect"), style="filled", size=(dp(100), dp(70)), @@ -792,7 +870,7 @@ class GameManager(ThemedApp): self.grid.add_widget(self.progressbar) # middle part - self.tabs = ClientTabs() + self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5}) self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago"))) self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in @@ -820,9 +898,10 @@ class GameManager(ThemedApp): self.grid.add_widget(self.main_area_container) # bottom part - bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(70), spacing=5, padding=(5, 10)) - info_button = MDButton(MDButtonText(text="Command:"), radius=5, style="filled", size=(dp(100), dp(70)), - size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.575}) + bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10)) + info_button = CommandButton(MDButtonText(text="Command:", halign="left"), manager=self, radius=5, + style="filled", size=(dp(100), dp(70)), size_hint_x=None, size_hint_y=None, + pos_hint={"center_y": 0.575}) info_button.bind(on_release=self.command_button_action) bottom_layout.add_widget(info_button) self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False) @@ -843,15 +922,27 @@ class GameManager(ThemedApp): self.server_connect_bar.focus = True self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s)) + # Uncomment to enable the kivy live editor console + # Press Ctrl-E (with numlock/capslock) disabled to open + # from kivy.core.window import Window + # from kivy.modules import console + # console.create_console(Window, self.container) + return self.container - def add_client_tab(self, title: str, content: Widget) -> Widget: + def add_client_tab(self, title: str, content: Widget, index: int = -1) -> 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 = MDTabsItem(MDTabsItemText(text=title)) new_tab.content = content - self.tabs.add_widget(new_tab) - self.tabs.carousel.add_widget(new_tab.content) + if -1 < index <= len(self.tabs.carousel.slides): + new_tab.bind(on_release=self.tabs.set_active_item) + new_tab._tabs = self.tabs + self.tabs.ids.container.add_widget(new_tab, index=index) + self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index) + else: + self.tabs.add_widget(new_tab) + self.tabs.carousel.add_widget(new_tab.content) return new_tab def update_texts(self, dt): @@ -1001,8 +1092,9 @@ class HintLayout(MDBoxLayout): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(55)) - boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(55))) + boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40)) + boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None, + height=dp(40), width=dp(75), halign="center", valign="center")) boxlayout.add_widget(AutocompleteHintInput()) self.add_widget(boxlayout) @@ -1012,7 +1104,7 @@ class HintLayout(MDBoxLayout): if fix_func: fix_func() - + status_names: typing.Dict[HintStatus, str] = { HintStatus.HINT_FOUND: "Found", HintStatus.HINT_UNSPECIFIED: "Unspecified", @@ -1109,6 +1201,7 @@ class HintLog(MDRecycleView): class ApAsyncImage(AsyncImage): + def is_uri(self, filename: str) -> bool: if filename.startswith("ap:"): return True @@ -1154,7 +1247,23 @@ class E(ExceptionHandler): class KivyJSONtoTextParser(JSONtoTextParser): # dummy class to absorb kvlang definitions class TextColors(Widget): - pass + white: str = StringProperty("FFFFFF") + black: str = StringProperty("000000") + red: str = StringProperty("EE0000") + green: str = StringProperty("00FF7F") + yellow: str = StringProperty("FAFAD2") + blue: str = StringProperty("6495ED") + magenta: str = StringProperty("EE00EE") + cyan: str = StringProperty("00EEEE") + slateblue: str = StringProperty("6D8BE8") + plum: str = StringProperty("AF99EF") + salmon: str = StringProperty("FA8072") + orange: str = StringProperty("FF7700") + # KivyMD parameters + theme_style: str = StringProperty("Dark") + primary_palette: str = StringProperty("Lightsteelblue") + dynamic_scheme_name: str = StringProperty("VIBRANT") + dynamic_scheme_contrast: int = NumericProperty(0) def __init__(self, *args, **kwargs): # we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries From 4b1898bfaf6522478ff738a3e99c5257903061f2 Mon Sep 17 00:00:00 2001 From: qwint Date: Thu, 17 Apr 2025 17:57:17 -0500 Subject: [PATCH 37/39] HK: fix docs whitespace (#4885) --- worlds/hk/docs/setup_en.md | 46 +++++++++++++++++------------------ worlds/hk/docs/setup_pt_br.md | 34 +++++++++++++------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/worlds/hk/docs/setup_en.md b/worlds/hk/docs/setup_en.md index 25f7c78075..0375867d40 100644 --- a/worlds/hk/docs/setup_en.md +++ b/worlds/hk/docs/setup_en.md @@ -3,34 +3,34 @@ ## Required Software * Download and unzip the Lumafly Mod Manager from the [Lumafly website](https://themulhima.github.io/Lumafly/). * A legal copy of Hollow Knight. - * Steam, Gog, and Xbox Game Pass versions of the game are supported. - * Windows, Mac, and Linux (including Steam Deck) are supported. + * Steam, Gog, and Xbox Game Pass versions of the game are supported. + * Windows, Mac, and Linux (including Steam Deck) are supported. ## Installing the Archipelago Mod using Lumafly 1. Launch Lumafly and ensure it locates your Hollow Knight installation directory. 2. Install the Archipelago mods by doing either of the following: - * Click one of the links below to allow Lumafly to install the mods. Lumafly will prompt for confirmation. - * [Archipelago and dependencies only](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago) - * [Archipelago with rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/) - (includes Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn, - and AdditionalMaps). - * Click the "Install" button near the "Archipelago" mod entry. If desired, also install "Archipelago Map Mod" - to use as an in-game tracker. + * Click one of the links below to allow Lumafly to install the mods. Lumafly will prompt for confirmation. + * [Archipelago and dependencies only](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago) + * [Archipelago with rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/) + (includes Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn, + and AdditionalMaps). + * Click the "Install" button near the "Archipelago" mod entry. If desired, also install "Archipelago Map Mod" + to use as an in-game tracker. 3. Launch the game, you're all set! ### What to do if Lumafly fails to find your installation directory 1. Find the directory manually. - * Xbox Game Pass: - 1. Enter the Xbox app and move your mouse over "Hollow Knight" on the left sidebar. - 2. Click the three points then click "Manage". - 3. Go to the "Files" tab and select "Browse...". - 4. Click "Hollow Knight", then "Content", then click the path bar and copy it. - * Steam: - 1. You likely put your Steam library in a non-standard place. If this is the case, you probably know where - it is. Find your steam library and then find the Hollow Knight folder and copy the path. - * 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 + * Xbox Game Pass: + 1. Enter the Xbox app and move your mouse over "Hollow Knight" on the left sidebar. + 2. Click the three points then click "Manage". + 3. Go to the "Files" tab and select "Browse...". + 4. Click "Hollow Knight", then "Content", then click the path bar and copy it. + * Steam: + 1. You likely put your Steam library in a non-standard place. If this is the case, you probably know where + it is. Find your steam library and then find the Hollow Knight folder and copy the path. + * 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. Run Lumafly as an administrator and, when it asks you for the path, paste the path you copied. ## Configuring your YAML File @@ -49,9 +49,9 @@ website to generate a YAML using a graphical interface. 4. Enter the correct settings for your Archipelago server. 5. Hit **Start** to begin the game. The game will stall for a few seconds while it does all item placements. 6. The game will immediately drop you into the randomized game. - * If you are waiting for a countdown then wait for it to lapse before hitting Start. - * Or hit Start then pause the game once you're in it. - + * If you are waiting for a countdown then wait for it to lapse before hitting Start. + * Or hit Start then pause the game once you're in it. + ## Hints and other commands While playing in a multiworld, you can interact with the server using various commands listed in the [commands guide](/tutorial/Archipelago/commands/en). You can use the Archipelago Text Client to do this, diff --git a/worlds/hk/docs/setup_pt_br.md b/worlds/hk/docs/setup_pt_br.md index 9ae1ea89d5..511ee0d552 100644 --- a/worlds/hk/docs/setup_pt_br.md +++ b/worlds/hk/docs/setup_pt_br.md @@ -3,28 +3,28 @@ ## 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. + * 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. + * 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` + * 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 @@ -43,9 +43,9 @@ para gerar o YAML usando a interface gráfica. 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. - + * 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, From 2dc55873f015f96ac0db361bd9495b66592715bc Mon Sep 17 00:00:00 2001 From: qwint Date: Thu, 17 Apr 2025 21:57:41 -0500 Subject: [PATCH 38/39] Webhost: add link to new session page (#4857) Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> --- WebHostLib/templates/userContent.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WebHostLib/templates/userContent.html b/WebHostLib/templates/userContent.html index 4e3747f4f9..fa60deacd8 100644 --- a/WebHostLib/templates/userContent.html +++ b/WebHostLib/templates/userContent.html @@ -29,7 +29,8 @@

User Content

- Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately. + Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
+ Sessions can be saved or synced across devices using the Sessions Page.

Your Rooms

{% if rooms %} From 38bfb1087b6cda27cfe36d303012cde6d9dbd27a Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 18 Apr 2025 11:15:59 -0500 Subject: [PATCH 39/39] Webhost: fix get_seeds api endpoint (#4889) --- WebHostLib/api/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebHostLib/api/user.py b/WebHostLib/api/user.py index 0ddb6fe83e..2524cc40a6 100644 --- a/WebHostLib/api/user.py +++ b/WebHostLib/api/user.py @@ -28,6 +28,6 @@ def get_seeds(): response.append({ "seed_id": seed.id, "creation_time": seed.creation_time, - "players": get_players(seed.slots), + "players": get_players(seed), }) return jsonify(response)