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 <drtchops@users.noreply.github.com>

* Address Tchops' review comments

---------

Co-authored-by: Ian Robinson <drtchops@users.noreply.github.com>
This commit is contained in:
NewSoupVi
2026-04-18 12:48:32 +01:00
committed by GitHub
parent 5f9e38b783
commit 66712bbd87
2 changed files with 94 additions and 73 deletions

View File

@@ -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"]
}

View File

@@ -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".