From 66712bbd87a15f387ec4776515267a32c36bdb12 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:48:32 +0100 Subject: [PATCH] APQuest: Switch to Rule Builder (#5906) * CachedRuleBuilderWorld * CachedRuleBuilderWorld * APQuest Rule Builder finished * Added comment Add a rule to check if the player has a Sword to destroy bushes. * Bump version + typo fix * Update worlds/apquest/rules.py Co-authored-by: Ian Robinson * Address Tchops' review comments --------- Co-authored-by: Ian Robinson --- worlds/apquest/archipelago.json | 4 +- worlds/apquest/rules.py | 163 ++++++++++++++++++-------------- 2 files changed, 94 insertions(+), 73 deletions(-) diff --git a/worlds/apquest/archipelago.json b/worlds/apquest/archipelago.json index 6a6c3ddd02..6392e06930 100644 --- a/worlds/apquest/archipelago.json +++ b/worlds/apquest/archipelago.json @@ -1,6 +1,6 @@ { "game": "APQuest", - "minimum_ap_version": "0.6.4", - "world_version": "1.0.1", + "minimum_ap_version": "0.6.7", + "world_version": "2.0.0", "authors": ["NewSoupVi"] } diff --git a/worlds/apquest/rules.py b/worlds/apquest/rules.py index 533c33d5ea..02c76daefc 100644 --- a/worlds/apquest/rules.py +++ b/worlds/apquest/rules.py @@ -2,12 +2,16 @@ from __future__ import annotations from typing import TYPE_CHECKING -from BaseClasses import CollectionState -from worlds.generic.Rules import add_rule, set_rule +from rule_builder.options import OptionFilter +from rule_builder.rules import Has, HasAll, Rule + +from .options import HardMode if TYPE_CHECKING: from .world import APQuestWorld +HAS_KEY = Has("Key") # Hmm, what could this be? A little foreshadowing perhaps? :) You'll find out if you keep reading! + def set_all_rules(world: APQuestWorld) -> None: # In order for AP to generate an item layout that is actually possible for the player to complete, @@ -26,36 +30,46 @@ def set_all_entrance_rules(world: APQuestWorld) -> None: overworld_to_top_left_room = world.get_entrance("Overworld to Top Left Room") right_room_to_final_boss_room = world.get_entrance("Right Room to Final Boss Room") - # An access rule is a function. We can define this function like any other function. - # This function must accept exactly one parameter: A "CollectionState". - # A CollectionState describes the current progress of the players in the multiworld, i.e. what items they have, - # which regions they've reached, etc. - # In an access rule, we can ask whether the player has a collected a certain item. - # We can do this via the state.has(...) function. - # This function takes an item name, a player number, and an optional count parameter (more on that below) - # Since a rule only takes a CollectionState parameter, but we also need the player number in the state.has call, - # our function needs to be locally defined so that it has access to the player number from the outer scope. - # In our case, we are inside a function that has access to the "world" parameter, so we can use world.player. - def can_destroy_bush(state: CollectionState) -> bool: - return state.has("Sword", world.player) + # Now, let's make some rules! + # First, let's handle the transition from the overworld to the bottom right room, + # which requires slashing a bush with the Sword. + # For this, we need a rule that says "player has a Sword". + # We can use a "Has"-type rule from the rule_builder module for this. + can_destroy_bush = Has("Sword") - # Now we can set our "can_destroy_bush" rule to our entrance which requires slashing a bush to clear the path. - # One way to set rules is via the set_rule() function, which works on both Entrances and Locations. - set_rule(overworld_to_bottom_right_room, can_destroy_bush) + # Now we can set our "can_destroy_bush" rule to the entrance which requires slashing a bush to clear the path. + # The easiest way to do this is by calling world.set_rule, which works for both Locations and Entrances. + world.set_rule(overworld_to_bottom_right_room, can_destroy_bush) - # Because the function has to be defined locally, most worlds prefer the lambda syntax. - set_rule(overworld_to_top_left_room, lambda state: state.has("Key", world.player)) - - # Conditions can depend on event items. - set_rule(right_room_to_final_boss_room, lambda state: state.has("Top Left Room Button Pressed", world.player)) + # Conditions can also depend on event items. + button_pressed = Has("Top Left Room Button Pressed") + world.set_rule(right_room_to_final_boss_room, button_pressed) # Some entrance rules may only apply if the player enabled certain options. # In our case, if the hammer option is enabled, we need to add the Hammer requirement to the Entrance from # Overworld to the Top Middle Room. if world.options.hammer: overworld_to_top_middle_room = world.get_entrance("Overworld to Top Middle Room") - set_rule(overworld_to_top_middle_room, lambda state: state.has("Hammer", world.player)) + can_smash_brick = Has("Hammer") + world.set_rule(overworld_to_top_middle_room, can_smash_brick) + # So far, we've been using "Has" from the Rule Builder to make our rules. + # There is another way to make rules that you will see in a lot of older worlds. + # A rule can just be a function that takes a "state" argument and returns a bool. + # As a demonstration of what that looks like, let's do it with our final Entrance rule: + world.set_rule(overworld_to_top_left_room, lambda state: state.has("Key", world.player)) + # This style is not really recommended anymore, though. + # Notice how you have to explicitly capture world.player here so that the rule applies to the correct player? + # Well, Rule Builder does this part for you, inside of world.set_rule. + # This doesn't just result in shorter code, it also means you can define rules statically (at the module level). + # APQuest opts to create its Rule objects locally, but just to show what this would look like, + # we'll re-set the "Overworld to Top Left Room" rule to a constant defined at the top of this file: + world.set_rule(overworld_to_top_left_room, HAS_KEY) + + # Beyond these structural advantages, + # Rule Builder also allows the core AP code to do a lot of under-the-hood optimizations. + # Rule Builder is quite comprehensive, and even if you have really esoteric rules, + # you can make custom rules by subclassing CustomRule. def set_all_location_rules(world: APQuestWorld) -> None: # Location rules work no differently from Entrance rules. @@ -67,65 +81,72 @@ def set_all_location_rules(world: APQuestWorld) -> None: # So, we need to set requirements on the Locations themselves. # Since combat is a bit more complicated, we'll use this chance to cover some advanced access rule concepts. - # Sometimes, you may want to have different rules depending on the player's chosen options. - # There is a wrong way to do this, and a right way to do this. Let's do the wrong way first. + # In "set_all_entrance_rules", we had a rule for a location that doesn't always exist. + # In this case, we had to check for its existence (by checking the player's chosen options) before setting the rule. + # Other times, you may have a situation where a location can have two different rules depending on the options. + # In our case, the enemy in the right room has more health if hard mode is selected, + # so ontop of the Sword, the player will either need one more health or a Shield in hard mode. + # First, let's make our sword condition. + can_defeat_basic_enemy: Rule = Has("Sword") + + # Next, we'll check whether hard mode has been chosen in the player options. + if world.options.hard_mode: + # We'll make the condition for "Has a Shield or a Health Upgrade". + # We can chain two "Has" conditions together with the | operator to make "Has Shield or has Health Upgrade". + can_withstand_a_hit = Has("Shield") | Has("Health Upgrade") + + # Now, we chain this rule to our Sword rule. + # Since we want both conditions to be true, in this case, we have to chain them in an "and" way. + # For this, we can use the & operator. + can_defeat_basic_enemy = can_defeat_basic_enemy & can_withstand_a_hit + + # Finally, we set our rule onto the Right Room Eney Drop location. right_room_enemy = world.get_location("Right Room Enemy Drop") + world.set_rule(right_room_enemy, can_defeat_basic_enemy) - # DON'T DO THIS!!!! - set_rule( - right_room_enemy, - lambda state: ( - state.has("Sword", world.player) - and (not world.options.hard_mode or state.has_any(("Shield", "Health Upgrade"), world.player)) - ), - ) - # DON'T DO THIS!!!! + # For the final boss, we also need to chain multiple conditions. + # First of all, you always need a Sword and a Shield. + # So far, we used the | and & operators to chain "Has" rules. + # Instead, we can also use HasAny for an or-chain of items, or HasAll for an and-chain of items. + has_sword_and_shield: Rule = HasAll("Sword", "Shield") - # Now, what's actually wrong with this? It works perfectly fine, right? - # If hard mode disabled, Sword is enough. If hard mode is enabled, we also need a Shield or a Health Upgrade. - # The access rule we just wrote does this correctly, so what's the problem? - # The problem is performance. - # Most of your world code doesn't need to be perfectly performant, since it just runs once per slot. - # However, access rules in particular are by far the hottest code path in Archipelago. - # An access rule will potentially be called thousands or even millions of times over the course of one generation. - # As a result, access rules are the one place where it's really worth putting in some effort to optimize. - # What's the performance problem here? - # Every time our access rule is called, it has to evaluate whether world.options.hard_mode is True or False. - # Wouldn't it be better if in easy mode, the access rule only checked for Sword to begin with? - # Wouldn't it also be better if in hard mode, it already knew it had to check Shield and Health Upgrade as well? - # Well, we can achieve this by doing the "if world.options.hard_mode" check outside the set_rule call, - # and instead having two *different* set_rule calls depending on which case we're in. + # In hard mode, the player also needs both Health Upgrades to survive long enough to defeat the boss. + # For this, we can use the optional "count" parameter for "Has". + has_both_health_upgrades = Has("Health Upgrade", count=2) - if world.options.hard_mode: - # If you have multiple conditions, you can obviously chain them via "or" or "and". - # However, there are also the nice helper functions "state.has_any" and "state.has_all". - set_rule( - right_room_enemy, - lambda state: ( - state.has("Sword", world.player) and state.has_any(("Shield", "Health Upgrade"), world.player) - ), - ) - else: - set_rule(right_room_enemy, lambda state: state.has("Sword", world.player)) + # Previously, we used an "if world.options.hard_mode" condition to check if we should apply the extra requirement. + # However, if you're comfortable with boolean logic, there is another way. + # OptionFilter is a rule component which isn't a "Rule" on its own, but when used in a boolean expression with + # rules, it acts like True if the option has the specified value, and acts like False otherwise. + hard_mode_is_off = OptionFilter(HardMode, False) - # Another way to chain multiple conditions is via the add_rule function. - # This makes the access rules a bit slower though, so it should only be used if your structure justifies it. - # In our case, it's pretty useful because hard mode and easy mode have different requirements. + # So with this option-checking rule component in hand, we can write our boss condition like this: + can_defeat_final_boss = has_sword_and_shield & (hard_mode_is_off | has_both_health_upgrades) + # If you're not as comfortable with boolean logic, it might be somewhat confusing why this is correct. + # There is nothing wrong with using "if" conditions to check for options, if you find that easier to understand. + + # Finally, we apply the rule to our "Final Boss Defeated" event location. final_boss = world.get_location("Final Boss Defeated") - - # For the "known" requirements, it's still better to chain them using a normal "and" condition. - add_rule(final_boss, lambda state: state.has_all(("Sword", "Shield"), world.player)) - - if world.options.hard_mode: - # You can check for multiple copies of an item by using the optional count parameter of state.has(). - add_rule(final_boss, lambda state: state.has("Health Upgrade", world.player, 2)) + world.set_rule(final_boss, can_defeat_final_boss) def set_completion_condition(world: APQuestWorld) -> None: # Finally, we need to set a completion condition for our world, defining what the player needs to win the game. + # For this, we can use world.set_completion_rule. # You can just set a completion condition directly like any other condition, referencing items the player receives: - world.multiworld.completion_condition[world.player] = lambda state: state.has_all(("Sword", "Shield"), world.player) + world.set_completion_rule(HasAll("Sword", "Shield")) # In our case, we went for the Victory event design pattern (see create_events() in locations.py). # So lets undo what we just did, and instead set the completion condition to: - world.multiworld.completion_condition[world.player] = lambda state: state.has("Victory", world.player) + world.set_completion_rule(Has("Victory")) + + +# One final comment about rules: +# If your world exclusively uses Rule Builder rules (like APQuest), it's worth trying CachedRuleBuilderWorld. +# CachedRuleBuilderWorld is a subclass of World that has a bunch of caching magic to make rules faster. +# Just have your world class subclass CachedRuleBuilderWorld instead of World: +# class APQuestWorld(CachedRuleBuilderWorld): ... +# This may speed up your world, or it may make it slower. +# The exact factors are complex and not well understood, but there is no harm in trying it. +# Generate a few seeds and see if there is a noticeable difference! +# If you're wondering, author has checked: APQuest is too simple to see any benefits, so we'll stick with "World".