mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-04-18 14:13:32 -07:00
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:
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user