forked from mirror/Archipelago
* initial commit of rules engine * implement most of the stuff * add docs and fill out rest of the functionality * add in explain functions * dedupe items and add more docs * pr feedback and optimization updates * Self is not in typing on 3.10 * fix test * Update docs/rule builder.md Co-authored-by: BadMagic100 <dempsey.sean@outlook.com> * pr feedback * love it when CI gives me different results than local * add composition with bitwise and and or * strongly typed option filtering * skip resolving location parent region * update docs * update typing and add decorator * add string explains * move simplify code to world * add wrapper rule * I may need to abandon the generic typing * missing space for faris * fix hashing for resolved rules * thank u typing extensions ilu * remove bad cacheable check * add decorator to assign hash and rule name * more type crimes... * region access rules are now cached * break compatibility so new features work * update docs * replace decorators with __init_subclass__ * ok now the frozen dataclass is automatic * one more type fix for the road * small fixes and caching tests * play nicer with tests * ok actually fix the tests * add item_mapping for faris * add more state helpers as rules * fix has from list rules * fix can reach location caching and add set completion condition * fix can reach entrance caching * implement HasGroup and HasGroupUnique * add more tests and fix some bugs * Add name arg to create_entrance Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com> * fix json dumping option filters * restructure and test serialization * add prop to disable caching * switch to __call__ and revert access_rule changes * update docs and make edge cases match * ruff has lured me into a false sense of security * also unused * fix disabling caching * move filter function to filter class * add more docs * tests for explain functions * Update docs/rule builder.md Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com> * chore: Strip out uses of TYPE_CHECKING as much as possible * chore: add empty webworld for test * chore: optimize rule evaluations * remove getattr from hot code paths * testing new cache flags * only clear cache for rules cached as false in collect * update test for new behaviour * do not have rules inherit from each other * update docs on caching * fix name of attribute * make explain messages more colorful * fix issue with combining rules with different options * add convenience functions for filtering * use an operator with higher precedence * name conflicts less with optionfilter * move simplify and instance caching code * update docs * kill resolve_rule * kill true_rule and false_rule * move helpers to base classes * update docs * I really should finish all of my * fix test * rename mixin * fix typos * refactor rule builder into folder for better imports * update docs * do not dupe collectionrule * docs review feedback * missed a file * remove rule_caching_enabled from base World * update docs on caching * shuffle around some docs * use option instead of option.value * add in operator and more testing * rm World = object * test fixes * move cache to logic mixin * keep test rule builder world out of global registry * todone * call register_dependencies automatically * move register deps call to call_single * add filtered_resolution * allow bool opts on filters * fix serialization tests * allow reverse operations --------- Co-authored-by: BadMagic100 <dempsey.sean@outlook.com> Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com>
1337 lines
57 KiB
Python
1337 lines
57 KiB
Python
import unittest
|
|
from dataclasses import dataclass, fields
|
|
from typing import Any, ClassVar, cast
|
|
|
|
from typing_extensions import override
|
|
|
|
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
|
from NetUtils import JSONMessagePart
|
|
from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Toggle
|
|
from rule_builder.cached_world import CachedRuleBuilderWorld
|
|
from rule_builder.options import Operator, OptionFilter
|
|
from rule_builder.rules import (
|
|
And,
|
|
CanReachEntrance,
|
|
CanReachLocation,
|
|
CanReachRegion,
|
|
False_,
|
|
Filtered,
|
|
Has,
|
|
HasAll,
|
|
HasAllCounts,
|
|
HasAny,
|
|
HasAnyCount,
|
|
HasFromList,
|
|
HasFromListUnique,
|
|
HasGroup,
|
|
HasGroupUnique,
|
|
Or,
|
|
Rule,
|
|
True_,
|
|
)
|
|
from test.general import setup_solo_multiworld
|
|
from test.param import classvar_matrix
|
|
from worlds.AutoWorld import AutoWorldRegister, World
|
|
|
|
|
|
class CachedCollectionState(CollectionState):
|
|
rule_builder_cache: dict[int, dict[int, bool]] # pyright: ignore[reportUninitializedInstanceVariable]
|
|
|
|
|
|
class ToggleOption(Toggle):
|
|
auto_display_name = True
|
|
|
|
|
|
class ChoiceOption(Choice):
|
|
auto_display_name = True
|
|
option_first = 0
|
|
option_second = 1
|
|
option_third = 2
|
|
default = 0
|
|
|
|
|
|
class FreeTextOption(FreeText):
|
|
auto_display_name = True
|
|
|
|
|
|
class SetOption(OptionSet):
|
|
auto_display_name = True
|
|
valid_keys: ClassVar[set[str]] = {"one", "two", "three"} # pyright: ignore[reportIncompatibleVariableOverride]
|
|
|
|
|
|
@dataclass
|
|
class RuleBuilderOptions(PerGameCommonOptions):
|
|
toggle_option: ToggleOption
|
|
choice_option: ChoiceOption
|
|
text_option: FreeTextOption
|
|
set_option: SetOption
|
|
|
|
|
|
GAME_NAME = "Rule Builder Test Game"
|
|
LOC_COUNT = 20
|
|
|
|
|
|
class RuleBuilderItem(Item):
|
|
game = GAME_NAME
|
|
|
|
|
|
class RuleBuilderLocation(Location):
|
|
game = GAME_NAME
|
|
|
|
|
|
class RuleBuilderTestCase(unittest.TestCase):
|
|
old_world_types: dict[str, type[World]] # pyright: ignore[reportUninitializedInstanceVariable]
|
|
world_cls: type[World] # pyright: ignore[reportUninitializedInstanceVariable]
|
|
|
|
@override
|
|
def setUp(self) -> None:
|
|
self.old_world_types = AutoWorldRegister.world_types.copy()
|
|
self._create_world_class()
|
|
|
|
@override
|
|
def tearDown(self) -> None:
|
|
AutoWorldRegister.world_types = self.old_world_types
|
|
assert GAME_NAME not in AutoWorldRegister.world_types
|
|
|
|
def _create_world_class(self) -> None:
|
|
class RuleBuilderWorld(World):
|
|
game = GAME_NAME
|
|
item_name_to_id: ClassVar = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)}
|
|
location_name_to_id: ClassVar = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)}
|
|
item_name_groups: ClassVar = {
|
|
"Group 1": {"Item 1", "Item 2", "Item 3"},
|
|
"Group 2": {"Item 4", "Item 5"},
|
|
}
|
|
hidden = True
|
|
options_dataclass = RuleBuilderOptions
|
|
options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride]
|
|
origin_region_name = "Region 1"
|
|
|
|
@override
|
|
def create_item(self, name: str) -> RuleBuilderItem:
|
|
classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression
|
|
return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player)
|
|
|
|
@override
|
|
def get_filler_item_name(self) -> str:
|
|
return "Filler"
|
|
|
|
self.world_cls = RuleBuilderWorld
|
|
|
|
|
|
class CachedRuleBuilderTestCase(RuleBuilderTestCase):
|
|
@override
|
|
def _create_world_class(self) -> None:
|
|
class RuleBuilderWorld(CachedRuleBuilderWorld):
|
|
game = GAME_NAME
|
|
item_name_to_id: ClassVar = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)}
|
|
location_name_to_id: ClassVar = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)}
|
|
item_name_groups: ClassVar = {
|
|
"Group 1": {"Item 1", "Item 2", "Item 3"},
|
|
"Group 2": {"Item 4", "Item 5"},
|
|
}
|
|
hidden = True
|
|
options_dataclass = RuleBuilderOptions
|
|
options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride]
|
|
origin_region_name = "Region 1"
|
|
|
|
@override
|
|
def create_item(self, name: str) -> RuleBuilderItem:
|
|
classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression
|
|
return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player)
|
|
|
|
@override
|
|
def get_filler_item_name(self) -> str:
|
|
return "Filler"
|
|
|
|
self.world_cls = RuleBuilderWorld
|
|
|
|
|
|
@classvar_matrix(
|
|
rules=(
|
|
(
|
|
And(Has("A", 1), Has("A", 2)),
|
|
Has.Resolved("A", 2, player=1),
|
|
),
|
|
(
|
|
And(Has("A"), HasAll("B", "C")),
|
|
HasAll.Resolved(("A", "B", "C"), player=1),
|
|
),
|
|
(
|
|
Or(Has("A", 1), Has("A", 2)),
|
|
Has.Resolved("A", 1, player=1),
|
|
),
|
|
(
|
|
Or(Has("A"), HasAny("B", "C")),
|
|
HasAny.Resolved(("A", "B", "C"), player=1),
|
|
),
|
|
(
|
|
Or(HasAll("A"), HasAll("A", "A")),
|
|
Has.Resolved("A", player=1),
|
|
),
|
|
(
|
|
Or(
|
|
Has("A"),
|
|
Or(
|
|
True_(options=[OptionFilter(ChoiceOption, 0)]),
|
|
HasAny("B", "C", options=[OptionFilter(ChoiceOption, 0, "gt")]),
|
|
options=[OptionFilter(ToggleOption, 1)],
|
|
),
|
|
And(Has("D"), Has("E"), options=[OptionFilter(ToggleOption, 0)]),
|
|
Has("F"),
|
|
),
|
|
Or.Resolved(
|
|
(
|
|
HasAll.Resolved(("D", "E"), player=1),
|
|
HasAny.Resolved(("A", "F"), player=1),
|
|
),
|
|
player=1,
|
|
),
|
|
),
|
|
(
|
|
Or(
|
|
Has("A"),
|
|
Or(
|
|
True_(options=[OptionFilter(ChoiceOption, 0, "gt")]),
|
|
HasAny("B", "C", options=[OptionFilter(ChoiceOption, 0)]),
|
|
options=[OptionFilter(ToggleOption, 0)],
|
|
),
|
|
And(Has("D"), Has("E"), options=[OptionFilter(ToggleOption, 1)]),
|
|
Has("F"),
|
|
),
|
|
HasAny.Resolved(("A", "B", "C", "F"), player=1),
|
|
),
|
|
(
|
|
And(Has("A"), True_()),
|
|
Has.Resolved("A", player=1),
|
|
),
|
|
(
|
|
And(Has("A"), False_()),
|
|
False_.Resolved(player=1),
|
|
),
|
|
(
|
|
Or(Has("A"), True_()),
|
|
True_.Resolved(player=1),
|
|
),
|
|
(
|
|
Or(Has("A"), False_()),
|
|
Has.Resolved("A", player=1),
|
|
),
|
|
(
|
|
And(Has("A"), HasAll("B", "C"), HasAllCounts({"D": 2, "E": 3})),
|
|
HasAllCounts.Resolved((("A", 1), ("B", 1), ("C", 1), ("D", 2), ("E", 3)), player=1),
|
|
),
|
|
(
|
|
And(Has("A"), HasAll("B", "C"), HasAllCounts({"D": 1, "E": 1})),
|
|
HasAll.Resolved(("A", "B", "C", "D", "E"), player=1),
|
|
),
|
|
(
|
|
Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 2, "E": 3})),
|
|
HasAnyCount.Resolved((("A", 1), ("B", 1), ("C", 1), ("D", 2), ("E", 3)), player=1),
|
|
),
|
|
(
|
|
Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 1, "E": 1})),
|
|
HasAny.Resolved(("A", "B", "C", "D", "E"), player=1),
|
|
),
|
|
)
|
|
)
|
|
class TestSimplify(RuleBuilderTestCase):
|
|
rules: ClassVar[tuple[Rule[Any], Rule.Resolved]]
|
|
|
|
def test_simplify(self) -> None:
|
|
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
|
|
world = multiworld.worlds[1]
|
|
rule, expected = self.rules
|
|
resolved_rule = rule.resolve(world)
|
|
self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}")
|
|
|
|
|
|
@classvar_matrix(
|
|
cases=(
|
|
(ToggleOption, 0, "eq", 0, True),
|
|
(ToggleOption, 0, "eq", 1, False),
|
|
(ToggleOption, 0, "ne", 0, False),
|
|
(ToggleOption, 0, "ne", 1, True),
|
|
(ChoiceOption, 0, "gt", 1, False),
|
|
(ChoiceOption, 1, "gt", 1, False),
|
|
(ChoiceOption, 2, "gt", 1, True),
|
|
(ChoiceOption, 0, "ge", "second", False),
|
|
(ChoiceOption, 1, "ge", "second", True),
|
|
(ChoiceOption, 1, "in", (0, 1), True),
|
|
(ChoiceOption, 1, "in", ("first", "second"), True),
|
|
(FreeTextOption, "no", "eq", "yes", False),
|
|
(FreeTextOption, "yes", "eq", "yes", True),
|
|
(SetOption, ("one", "two"), "contains", "three", False),
|
|
(SetOption, ("one", "two"), "contains", "two", True),
|
|
)
|
|
)
|
|
class TestOptions(RuleBuilderTestCase):
|
|
cases: ClassVar[tuple[type[Option[Any]], Any, Operator, Any, bool]]
|
|
|
|
def test_option_resolution(self) -> None:
|
|
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
|
|
world = multiworld.worlds[1]
|
|
option_cls, world_value, operator, filter_value, expected = self.cases
|
|
|
|
for field in fields(world.options_dataclass):
|
|
if field.type is option_cls:
|
|
setattr(world.options, field.name, option_cls.from_any(world_value))
|
|
break
|
|
|
|
option_filter = OptionFilter(option_cls, filter_value, operator)
|
|
result = option_filter.check(world.options)
|
|
self.assertEqual(result, expected, f"Expected {result} for option={option_filter} with value={world_value}")
|
|
|
|
|
|
class TestFilteredResolution(RuleBuilderTestCase):
|
|
def test_filtered_resolution(self) -> None:
|
|
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
|
|
world = multiworld.worlds[1]
|
|
|
|
rule_and_false = Has("A") & Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=False)
|
|
rule_and_true = Has("A") & Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=True)
|
|
rule_or_false = Has("A") | Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=False)
|
|
rule_or_true = Has("A") | Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=True)
|
|
|
|
# option fails check
|
|
world.options.toggle_option.value = 0 # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
|
|
self.assertEqual(rule_and_false.resolve(world), False_.Resolved(player=1))
|
|
self.assertEqual(rule_and_true.resolve(world), Has.Resolved("A", player=1))
|
|
self.assertEqual(rule_or_false.resolve(world), Has.Resolved("A", player=1))
|
|
self.assertEqual(rule_or_true.resolve(world), True_.Resolved(player=1))
|
|
|
|
# option passes check
|
|
world.options.toggle_option.value = 1 # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
|
|
self.assertEqual(rule_and_false.resolve(world), HasAll.Resolved(("A", "B"), player=1))
|
|
self.assertEqual(rule_and_true.resolve(world), HasAll.Resolved(("A", "B"), player=1))
|
|
self.assertEqual(rule_or_false.resolve(world), HasAny.Resolved(("A", "B"), player=1))
|
|
self.assertEqual(rule_or_true.resolve(world), HasAny.Resolved(("A", "B"), player=1))
|
|
|
|
|
|
@classvar_matrix(
|
|
rules=(
|
|
(
|
|
Has("A") & Has("B"),
|
|
And(Has("A"), Has("B")),
|
|
),
|
|
(
|
|
Has("A") | Has("B"),
|
|
Or(Has("A"), Has("B")),
|
|
),
|
|
(
|
|
And(Has("A")) & Has("B"),
|
|
And(Has("A"), Has("B")),
|
|
),
|
|
(
|
|
And(Has("A"), Has("B")) & And(Has("C")),
|
|
And(Has("A"), Has("B"), Has("C")),
|
|
),
|
|
(
|
|
And(Has("A"), Has("B")) | Or(Has("C"), Has("D")),
|
|
Or(And(Has("A"), Has("B")), Has("C"), Has("D")),
|
|
),
|
|
(
|
|
Or(Has("A")) | Or(Has("B"), options=[OptionFilter(ToggleOption, 1)]),
|
|
Or(Or(Has("A")), Or(Has("B"), options=[OptionFilter(ToggleOption, 1)])),
|
|
),
|
|
(
|
|
(
|
|
And(Has("A"), options=[OptionFilter(ToggleOption, 1)])
|
|
& And(Has("B"), options=[OptionFilter(ToggleOption, 1)])
|
|
),
|
|
And(Has("A"), Has("B"), options=[OptionFilter(ToggleOption, 1)]),
|
|
),
|
|
(
|
|
Has("A") & Has("B") & Has("C"),
|
|
And(Has("A"), Has("B"), Has("C")),
|
|
),
|
|
(
|
|
Has("A") & Has("B") | Has("C") & Has("D"),
|
|
Or(And(Has("A"), Has("B")), And(Has("C"), Has("D"))),
|
|
),
|
|
(
|
|
Has("A") | Or(Has("B"), options=[OptionFilter(ToggleOption, 1)]),
|
|
Or(Has("A"), Or(Has("B"), options=[OptionFilter(ToggleOption, 1)])),
|
|
),
|
|
(
|
|
Has("A") & And(Has("B"), options=[OptionFilter(ToggleOption, 1)]),
|
|
And(Has("A"), And(Has("B"), options=[OptionFilter(ToggleOption, 1)])),
|
|
),
|
|
(
|
|
(Has("A") | Has("B")) & [OptionFilter(ToggleOption, 1)],
|
|
Filtered(Or(Has("A"), Has("B")), options=[OptionFilter(ToggleOption, 1)]),
|
|
),
|
|
(
|
|
(Has("A") | Has("B")) | OptionFilter(ToggleOption, 1),
|
|
Or(Or(Has("A"), Has("B")), True_(options=[OptionFilter(ToggleOption, 1)])),
|
|
),
|
|
(
|
|
OptionFilter(ToggleOption, 1) & (Has("A") | Has("B")),
|
|
Filtered(Or(Has("A"), Has("B")), options=[OptionFilter(ToggleOption, 1)]),
|
|
),
|
|
(
|
|
[OptionFilter(ToggleOption, 1)] | (Has("A") | Has("B")),
|
|
Or(Or(Has("A"), Has("B")), True_(options=[OptionFilter(ToggleOption, 1)])),
|
|
),
|
|
)
|
|
)
|
|
class TestComposition(unittest.TestCase):
|
|
rules: ClassVar[tuple[Rule[Any], Rule[Any]]]
|
|
|
|
def test_composition(self) -> None:
|
|
combined_rule, expected = self.rules
|
|
self.assertEqual(combined_rule, expected, str(combined_rule))
|
|
|
|
|
|
class TestHashes(RuleBuilderTestCase):
|
|
def test_and_hash(self) -> None:
|
|
rule1 = And.Resolved((True_.Resolved(player=1),), player=1)
|
|
rule2 = And.Resolved((True_.Resolved(player=1),), player=1)
|
|
rule3 = Or.Resolved((True_.Resolved(player=1),), player=1)
|
|
|
|
self.assertEqual(hash(rule1), hash(rule2))
|
|
self.assertNotEqual(hash(rule1), hash(rule3))
|
|
|
|
def test_has_all_hash(self) -> None:
|
|
multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0)
|
|
world = multiworld.worlds[1]
|
|
rule1 = HasAll("1", "2")
|
|
rule2 = HasAll("2", "2", "2", "1")
|
|
self.assertEqual(hash(rule1.resolve(world)), hash(rule2.resolve(world)))
|
|
|
|
|
|
class TestCaching(CachedRuleBuilderTestCase):
|
|
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
|
|
world: World # pyright: ignore[reportUninitializedInstanceVariable]
|
|
state: CachedCollectionState # pyright: ignore[reportUninitializedInstanceVariable]
|
|
player: int = 1
|
|
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
|
|
self.multiworld = setup_solo_multiworld(self.world_cls, seed=0)
|
|
world = self.multiworld.worlds[1]
|
|
self.world = world
|
|
self.state = cast(CachedCollectionState, self.multiworld.state)
|
|
|
|
region1 = Region("Region 1", self.player, self.multiworld)
|
|
region2 = Region("Region 2", self.player, self.multiworld)
|
|
region3 = Region("Region 3", self.player, self.multiworld)
|
|
self.multiworld.regions.extend([region1, region2, region3])
|
|
|
|
region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderLocation)
|
|
region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation)
|
|
region3.add_locations({"Location 5": 5}, RuleBuilderLocation)
|
|
|
|
world.create_entrance(region1, region2, Has("Item 1"))
|
|
world.create_entrance(region1, region3, HasAny("Item 3", "Item 4"))
|
|
world.set_rule(world.get_location("Location 2"), CanReachRegion("Region 2") & Has("Item 2"))
|
|
world.set_rule(world.get_location("Location 4"), HasAll("Item 2", "Item 3"))
|
|
world.set_rule(world.get_location("Location 5"), CanReachLocation("Location 4"))
|
|
world.set_rule(world.get_location("Location 6"), CanReachEntrance("Region 1 -> Region 2") & Has("Item 2"))
|
|
|
|
for i in range(1, LOC_COUNT + 1):
|
|
self.multiworld.itempool.append(world.create_item(f"Item {i}"))
|
|
|
|
world.register_rule_builder_dependencies()
|
|
|
|
def test_item_cache_busting(self) -> None:
|
|
location = self.world.get_location("Location 4")
|
|
self.state.collect(self.world.create_item("Item 1")) # access to region 2
|
|
self.state.collect(self.world.create_item("Item 2")) # item directly needed
|
|
self.assertFalse(location.can_reach(self.state)) # populates cache
|
|
self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)])
|
|
|
|
self.state.collect(self.world.create_item("Item 3")) # clears cache, item directly needed
|
|
self.assertNotIn(id(location.access_rule), self.state.rule_builder_cache[1])
|
|
self.assertTrue(location.can_reach(self.state))
|
|
self.assertTrue(self.state.rule_builder_cache[1][id(location.access_rule)])
|
|
self.state.collect(self.world.create_item("Item 3")) # does not clear cache as rule is already true
|
|
self.assertTrue(self.state.rule_builder_cache[1][id(location.access_rule)])
|
|
|
|
def test_region_cache_busting(self) -> None:
|
|
location = self.world.get_location("Location 2")
|
|
self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule
|
|
self.assertFalse(location.can_reach(self.state)) # populates cache
|
|
self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)])
|
|
|
|
self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for region 2 access
|
|
# cache gets cleared during the can_reach
|
|
self.assertTrue(location.can_reach(self.state))
|
|
self.assertTrue(self.state.rule_builder_cache[1][id(location.access_rule)])
|
|
|
|
def test_location_cache_busting(self) -> None:
|
|
location = self.world.get_location("Location 5")
|
|
self.state.collect(self.world.create_item("Item 1")) # access to region 2
|
|
self.state.collect(self.world.create_item("Item 3")) # access to region 3
|
|
self.assertFalse(location.can_reach(self.state)) # populates cache
|
|
self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)])
|
|
|
|
self.state.collect(self.world.create_item("Item 2")) # clears cache, item only needed for location 2 access
|
|
self.assertNotIn(id(location.access_rule), self.state.rule_builder_cache[1])
|
|
self.assertTrue(location.can_reach(self.state))
|
|
|
|
def test_entrance_cache_busting(self) -> None:
|
|
location = self.world.get_location("Location 6")
|
|
self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule
|
|
self.assertFalse(location.can_reach(self.state)) # populates cache
|
|
self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)])
|
|
|
|
self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for entrance access
|
|
self.assertNotIn(id(location.access_rule), self.state.rule_builder_cache[1])
|
|
self.assertTrue(location.can_reach(self.state))
|
|
|
|
def test_has_skips_cache(self) -> None:
|
|
entrance = self.world.get_entrance("Region 1 -> Region 2")
|
|
self.assertFalse(entrance.can_reach(self.state)) # does not populates cache
|
|
self.assertNotIn(id(entrance.access_rule), self.state.rule_builder_cache[1])
|
|
|
|
self.state.collect(self.world.create_item("Item 1")) # no need to clear cache, item directly needed
|
|
self.assertNotIn(id(entrance.access_rule), self.state.rule_builder_cache[1])
|
|
self.assertTrue(entrance.can_reach(self.state))
|
|
|
|
|
|
class TestCacheDisabled(RuleBuilderTestCase):
|
|
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
|
|
world: World # pyright: ignore[reportUninitializedInstanceVariable]
|
|
state: CachedCollectionState # pyright: ignore[reportUninitializedInstanceVariable]
|
|
player: int = 1
|
|
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
|
|
self.multiworld = setup_solo_multiworld(self.world_cls, seed=0)
|
|
world = self.multiworld.worlds[1]
|
|
self.world = world
|
|
self.state = cast(CachedCollectionState, self.multiworld.state)
|
|
|
|
region1 = Region("Region 1", self.player, self.multiworld)
|
|
region2 = Region("Region 2", self.player, self.multiworld)
|
|
region3 = Region("Region 3", self.player, self.multiworld)
|
|
self.multiworld.regions.extend([region1, region2, region3])
|
|
|
|
region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderLocation)
|
|
region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation)
|
|
region3.add_locations({"Location 5": 5}, RuleBuilderLocation)
|
|
|
|
world.create_entrance(region1, region2, Has("Item 1"))
|
|
world.create_entrance(region1, region3, HasAny("Item 3", "Item 4"))
|
|
world.set_rule(world.get_location("Location 2"), CanReachRegion("Region 2") & Has("Item 2"))
|
|
world.set_rule(world.get_location("Location 4"), HasAll("Item 2", "Item 3"))
|
|
world.set_rule(world.get_location("Location 5"), CanReachLocation("Location 4"))
|
|
world.set_rule(world.get_location("Location 6"), CanReachEntrance("Region 1 -> Region 2") & Has("Item 2"))
|
|
|
|
for i in range(1, LOC_COUNT + 1):
|
|
self.multiworld.itempool.append(world.create_item(f"Item {i}"))
|
|
|
|
def test_item_logic(self) -> None:
|
|
entrance = self.world.get_entrance("Region 1 -> Region 2")
|
|
self.assertFalse(entrance.can_reach(self.state))
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
|
|
self.state.collect(self.world.create_item("Item 1")) # item directly needed
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
self.assertTrue(entrance.can_reach(self.state))
|
|
|
|
def test_region_logic(self) -> None:
|
|
location = self.world.get_location("Location 2")
|
|
self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule
|
|
self.assertFalse(location.can_reach(self.state))
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
|
|
self.state.collect(self.world.create_item("Item 1")) # item only needed for region 2 access
|
|
self.assertTrue(location.can_reach(self.state))
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
|
|
def test_location_logic(self) -> None:
|
|
location = self.world.get_location("Location 5")
|
|
self.state.collect(self.world.create_item("Item 1")) # access to region 2
|
|
self.state.collect(self.world.create_item("Item 3")) # access to region 3
|
|
self.assertFalse(location.can_reach(self.state))
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
|
|
self.state.collect(self.world.create_item("Item 2")) # item only needed for location 2 access
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
self.assertTrue(location.can_reach(self.state))
|
|
|
|
def test_entrance_logic(self) -> None:
|
|
location = self.world.get_location("Location 6")
|
|
self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule
|
|
self.assertFalse(location.can_reach(self.state))
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
|
|
self.state.collect(self.world.create_item("Item 1")) # item only needed for entrance access
|
|
self.assertFalse(self.state.rule_builder_cache[1])
|
|
self.assertTrue(location.can_reach(self.state))
|
|
|
|
|
|
class TestRules(RuleBuilderTestCase):
|
|
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
|
|
world: World # pyright: ignore[reportUninitializedInstanceVariable]
|
|
state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable]
|
|
player: int = 1
|
|
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
|
|
self.multiworld = setup_solo_multiworld(self.world_cls, seed=0)
|
|
world = self.multiworld.worlds[1]
|
|
self.world = world
|
|
self.state = self.multiworld.state
|
|
|
|
def test_true(self) -> None:
|
|
rule = True_()
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertTrue(resolved_rule(self.state))
|
|
|
|
def test_false(self) -> None:
|
|
rule = False_()
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has(self) -> None:
|
|
rule = Has("Item 1")
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item = self.world.create_item("Item 1")
|
|
self.state.collect(item)
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(item)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_all(self) -> None:
|
|
rule = HasAll("Item 1", "Item 2")
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item1 = self.world.create_item("Item 1")
|
|
self.state.collect(item1)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item2 = self.world.create_item("Item 2")
|
|
self.state.collect(item2)
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(item1)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_any(self) -> None:
|
|
item_names = ("Item 1", "Item 2")
|
|
rule = HasAny(*item_names)
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
for item_name in item_names:
|
|
item = self.world.create_item(item_name)
|
|
self.state.collect(item)
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(item)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_all_counts(self) -> None:
|
|
rule = HasAllCounts({"Item 1": 1, "Item 2": 2})
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item1 = self.world.create_item("Item 1")
|
|
self.state.collect(item1)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item2 = self.world.create_item("Item 2")
|
|
self.state.collect(item2)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item2 = self.world.create_item("Item 2")
|
|
self.state.collect(item2)
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(item2)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_any_count(self) -> None:
|
|
item_counts = {"Item 1": 1, "Item 2": 2}
|
|
rule = HasAnyCount(item_counts)
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
|
|
for item_name, count in item_counts.items():
|
|
item = self.world.create_item(item_name)
|
|
for _ in range(count):
|
|
self.assertFalse(resolved_rule(self.state))
|
|
self.state.collect(item)
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(item)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_from_list(self) -> None:
|
|
item_names = ("Item 1", "Item 2", "Item 3")
|
|
rule = HasFromList(*item_names, count=2)
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
items: list[Item] = []
|
|
for i, item_name in enumerate(item_names):
|
|
item = self.world.create_item(item_name)
|
|
self.state.collect(item)
|
|
items.append(item)
|
|
if i == 0:
|
|
self.assertFalse(resolved_rule(self.state))
|
|
else:
|
|
self.assertTrue(resolved_rule(self.state))
|
|
|
|
for i in range(2):
|
|
self.state.remove(items[i])
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_from_list_unique(self) -> None:
|
|
item_names = ("Item 1", "Item 1", "Item 2")
|
|
rule = HasFromListUnique(*item_names, count=2)
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
items: list[Item] = []
|
|
for i, item_name in enumerate(item_names):
|
|
item = self.world.create_item(item_name)
|
|
self.state.collect(item)
|
|
items.append(item)
|
|
if i < 2:
|
|
self.assertFalse(resolved_rule(self.state))
|
|
else:
|
|
self.assertTrue(resolved_rule(self.state))
|
|
|
|
self.state.remove(items[0])
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(items[1])
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_group(self) -> None:
|
|
rule = HasGroup("Group 1", count=2)
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
|
|
items: list[Item] = []
|
|
for item_name in ("Item 1", "Item 2"):
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item = self.world.create_item(item_name)
|
|
self.state.collect(item)
|
|
items.append(item)
|
|
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(items[0])
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_has_group_unique(self) -> None:
|
|
rule = HasGroupUnique("Group 1", count=2)
|
|
resolved_rule = rule.resolve(self.world)
|
|
self.world.register_rule_dependencies(resolved_rule)
|
|
|
|
items: list[Item] = []
|
|
for item_name in ("Item 1", "Item 1", "Item 2"):
|
|
self.assertFalse(resolved_rule(self.state))
|
|
item = self.world.create_item(item_name)
|
|
self.state.collect(item)
|
|
items.append(item)
|
|
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(items[0])
|
|
self.assertTrue(resolved_rule(self.state))
|
|
self.state.remove(items[1])
|
|
self.assertFalse(resolved_rule(self.state))
|
|
|
|
def test_completion_rule(self) -> None:
|
|
rule = Has("Item 1")
|
|
self.world.set_completion_rule(rule)
|
|
self.assertEqual(self.multiworld.can_beat_game(self.state), False)
|
|
self.state.collect(self.world.create_item("Item 1"))
|
|
self.assertEqual(self.multiworld.can_beat_game(self.state), True)
|
|
|
|
|
|
class TestSerialization(RuleBuilderTestCase):
|
|
maxDiff: int | None = None
|
|
|
|
rule: ClassVar[Rule[Any]] = And(
|
|
Or(
|
|
Has("i1", count=4),
|
|
HasFromList("i2", "i3", "i4", count=2),
|
|
HasAnyCount({"i5": 2, "i6": 3}),
|
|
options=[OptionFilter(ToggleOption, 0)],
|
|
),
|
|
Or(
|
|
HasAll("i7", "i8"),
|
|
HasAllCounts(
|
|
{"i9": 1, "i10": 5},
|
|
options=[OptionFilter(ToggleOption, 1, operator="ne")],
|
|
filtered_resolution=True,
|
|
),
|
|
CanReachRegion("r1"),
|
|
HasGroup("g1"),
|
|
),
|
|
And(
|
|
HasAny("i11", "i12"),
|
|
CanReachLocation("l1", "r2"),
|
|
HasFromListUnique("i13", "i14"),
|
|
options=[
|
|
OptionFilter(ToggleOption, ToggleOption.option_false),
|
|
OptionFilter(ChoiceOption, ChoiceOption.option_second, "ge"),
|
|
],
|
|
),
|
|
CanReachEntrance("e1"),
|
|
HasGroupUnique("g2", count=5),
|
|
)
|
|
|
|
rule_dict: ClassVar[dict[str, Any]] = {
|
|
"rule": "And",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"children": [
|
|
{
|
|
"rule": "Or",
|
|
"options": [
|
|
{
|
|
"option": "test.general.test_rule_builder.ToggleOption",
|
|
"value": 0,
|
|
"operator": "eq",
|
|
},
|
|
],
|
|
"filtered_resolution": False,
|
|
"children": [
|
|
{
|
|
"rule": "Has",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_name": "i1", "count": 4},
|
|
},
|
|
{
|
|
"rule": "HasFromList",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_names": ("i2", "i3", "i4"), "count": 2},
|
|
},
|
|
{
|
|
"rule": "HasAnyCount",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_counts": {"i5": 2, "i6": 3}},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"rule": "Or",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"children": [
|
|
{
|
|
"rule": "HasAll",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_names": ("i7", "i8")},
|
|
},
|
|
{
|
|
"rule": "HasAllCounts",
|
|
"options": [
|
|
{
|
|
"option": "test.general.test_rule_builder.ToggleOption",
|
|
"value": 1,
|
|
"operator": "ne",
|
|
},
|
|
],
|
|
"filtered_resolution": True,
|
|
"args": {"item_counts": {"i9": 1, "i10": 5}},
|
|
},
|
|
{
|
|
"rule": "CanReachRegion",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"region_name": "r1"},
|
|
},
|
|
{
|
|
"rule": "HasGroup",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_name_group": "g1", "count": 1},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"rule": "And",
|
|
"options": [
|
|
{
|
|
"option": "test.general.test_rule_builder.ToggleOption",
|
|
"value": 0,
|
|
"operator": "eq",
|
|
},
|
|
{
|
|
"option": "test.general.test_rule_builder.ChoiceOption",
|
|
"value": 1,
|
|
"operator": "ge",
|
|
},
|
|
],
|
|
"filtered_resolution": False,
|
|
"children": [
|
|
{
|
|
"rule": "HasAny",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_names": ("i11", "i12")},
|
|
},
|
|
{
|
|
"rule": "CanReachLocation",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"location_name": "l1", "parent_region_name": "r2", "skip_indirect_connection": False},
|
|
},
|
|
{
|
|
"rule": "HasFromListUnique",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_names": ("i13", "i14"), "count": 1},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"rule": "CanReachEntrance",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"entrance_name": "e1", "parent_region_name": ""},
|
|
},
|
|
{
|
|
"rule": "HasGroupUnique",
|
|
"options": [],
|
|
"filtered_resolution": False,
|
|
"args": {"item_name_group": "g2", "count": 5},
|
|
},
|
|
],
|
|
}
|
|
|
|
def test_serialize(self) -> None:
|
|
serialized_rule = self.rule.to_dict()
|
|
self.assertDictEqual(serialized_rule, self.rule_dict)
|
|
|
|
def test_deserialize(self) -> None:
|
|
multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0)
|
|
world = multiworld.worlds[1]
|
|
deserialized_rule = world.rule_from_dict(self.rule_dict)
|
|
self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule))
|
|
|
|
|
|
class TestExplain(RuleBuilderTestCase):
|
|
multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable]
|
|
world: World # pyright: ignore[reportUninitializedInstanceVariable]
|
|
state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable]
|
|
player: int = 1
|
|
|
|
resolved_rule: ClassVar[Rule.Resolved] = And.Resolved(
|
|
(
|
|
Or.Resolved(
|
|
(
|
|
Has.Resolved("Item 1", count=4, player=1),
|
|
HasAll.Resolved(("Item 2", "Item 3"), player=1),
|
|
HasAny.Resolved(("Item 4", "Item 5"), player=1),
|
|
),
|
|
player=1,
|
|
),
|
|
HasAllCounts.Resolved((("Item 6", 1), ("Item 7", 5)), player=1),
|
|
HasAnyCount.Resolved((("Item 8", 2), ("Item 9", 3)), player=1),
|
|
HasFromList.Resolved(("Item 10", "Item 11", "Item 12"), count=2, player=1),
|
|
HasFromListUnique.Resolved(("Item 13", "Item 14"), player=1),
|
|
HasGroup.Resolved("Group 1", ("Item 15", "Item 16", "Item 17"), player=1),
|
|
HasGroupUnique.Resolved("Group 2", ("Item 18", "Item 19"), count=2, player=1),
|
|
CanReachRegion.Resolved("Region 2", player=1),
|
|
CanReachLocation.Resolved("Location 2", "Region 2", player=1),
|
|
CanReachEntrance.Resolved("Entrance 2", "Region 2", player=1),
|
|
True_.Resolved(player=1),
|
|
False_.Resolved(player=1),
|
|
),
|
|
player=1,
|
|
)
|
|
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
|
|
self.multiworld = setup_solo_multiworld(self.world_cls, seed=0)
|
|
world = self.multiworld.worlds[1]
|
|
self.world = world
|
|
self.state = self.multiworld.state
|
|
|
|
region1 = Region("Region 1", self.player, self.multiworld)
|
|
region2 = Region("Region 2", self.player, self.multiworld)
|
|
region3 = Region("Region 3", self.player, self.multiworld)
|
|
self.multiworld.regions.extend([region1, region2, region3])
|
|
|
|
region2.add_locations({"Location 2": 1}, RuleBuilderLocation)
|
|
world.create_entrance(region1, region2, Has("Item 1"))
|
|
world.create_entrance(region2, region3, name="Entrance 2")
|
|
|
|
def _collect_all(self) -> None:
|
|
for i in range(1, LOC_COUNT + 1):
|
|
for _ in range(10):
|
|
item = self.world.create_item(f"Item {i}")
|
|
self.state.collect(item)
|
|
|
|
def test_explain_json_with_state_no_items(self) -> None:
|
|
expected: list[JSONMessagePart] = [
|
|
{"type": "text", "text": "("},
|
|
{"type": "text", "text": "("},
|
|
{"type": "text", "text": "Missing "},
|
|
{"type": "color", "color": "cyan", "text": "4"},
|
|
{"type": "text", "text": "x "},
|
|
{"type": "color", "color": "salmon", "text": "Item 1"},
|
|
{"type": "text", "text": " | "},
|
|
{"type": "text", "text": "Missing "},
|
|
{"type": "color", "color": "cyan", "text": "some"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Missing: "},
|
|
{"type": "color", "color": "salmon", "text": "Item 2"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "salmon", "text": "Item 3"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " | "},
|
|
{"type": "text", "text": "Missing "},
|
|
{"type": "color", "color": "cyan", "text": "all"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Missing: "},
|
|
{"type": "color", "color": "salmon", "text": "Item 4"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "salmon", "text": "Item 5"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Missing "},
|
|
{"type": "color", "color": "cyan", "text": "some"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Missing: "},
|
|
{"type": "color", "color": "salmon", "text": "Item 6"},
|
|
{"type": "text", "text": " x1"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "salmon", "text": "Item 7"},
|
|
{"type": "text", "text": " x5"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Missing "},
|
|
{"type": "color", "color": "cyan", "text": "all"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Missing: "},
|
|
{"type": "color", "color": "salmon", "text": "Item 8"},
|
|
{"type": "text", "text": " x2"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "salmon", "text": "Item 9"},
|
|
{"type": "text", "text": " x3"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "salmon", "text": "0/2"},
|
|
{"type": "text", "text": " items from ("},
|
|
{"type": "text", "text": "Missing: "},
|
|
{"type": "color", "color": "salmon", "text": "Item 10"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "salmon", "text": "Item 11"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "salmon", "text": "Item 12"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "salmon", "text": "0/1"},
|
|
{"type": "text", "text": " unique items from ("},
|
|
{"type": "text", "text": "Missing: "},
|
|
{"type": "color", "color": "salmon", "text": "Item 13"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "salmon", "text": "Item 14"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "salmon", "text": "0/1"},
|
|
{"type": "text", "text": " items from "},
|
|
{"type": "color", "color": "cyan", "text": "Group 1"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "salmon", "text": "0/2"},
|
|
{"type": "text", "text": " unique items from "},
|
|
{"type": "color", "color": "cyan", "text": "Group 2"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Cannot reach region "},
|
|
{"type": "color", "color": "yellow", "text": "Region 2"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Cannot reach location "},
|
|
{"type": "location_name", "text": "Location 2", "player": 1},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Cannot reach entrance "},
|
|
{"type": "entrance_name", "text": "Entrance 2", "player": 1},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "color", "color": "green", "text": "True"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "color", "color": "salmon", "text": "False"},
|
|
{"type": "text", "text": ")"},
|
|
]
|
|
assert self.resolved_rule.explain_json(self.state) == expected
|
|
|
|
def test_explain_json_with_state_all_items(self) -> None:
|
|
self._collect_all()
|
|
|
|
expected: list[JSONMessagePart] = [
|
|
{"type": "text", "text": "("},
|
|
{"type": "text", "text": "("},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "4"},
|
|
{"type": "text", "text": "x "},
|
|
{"type": "color", "color": "green", "text": "Item 1"},
|
|
{"type": "text", "text": " | "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "all"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Found: "},
|
|
{"type": "color", "color": "green", "text": "Item 2"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "green", "text": "Item 3"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " | "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "some"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Found: "},
|
|
{"type": "color", "color": "green", "text": "Item 4"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "green", "text": "Item 5"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "all"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Found: "},
|
|
{"type": "color", "color": "green", "text": "Item 6"},
|
|
{"type": "text", "text": " x1"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "green", "text": "Item 7"},
|
|
{"type": "text", "text": " x5"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "some"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "text", "text": "Found: "},
|
|
{"type": "color", "color": "green", "text": "Item 8"},
|
|
{"type": "text", "text": " x2"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "green", "text": "Item 9"},
|
|
{"type": "text", "text": " x3"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "green", "text": "30/2"},
|
|
{"type": "text", "text": " items from ("},
|
|
{"type": "text", "text": "Found: "},
|
|
{"type": "color", "color": "green", "text": "Item 10"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "green", "text": "Item 11"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "green", "text": "Item 12"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "green", "text": "2/1"},
|
|
{"type": "text", "text": " unique items from ("},
|
|
{"type": "text", "text": "Found: "},
|
|
{"type": "color", "color": "green", "text": "Item 13"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "color", "color": "green", "text": "Item 14"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "green", "text": "30/1"},
|
|
{"type": "text", "text": " items from "},
|
|
{"type": "color", "color": "cyan", "text": "Group 1"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "green", "text": "2/2"},
|
|
{"type": "text", "text": " unique items from "},
|
|
{"type": "color", "color": "cyan", "text": "Group 2"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Reached region "},
|
|
{"type": "color", "color": "yellow", "text": "Region 2"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Reached location "},
|
|
{"type": "location_name", "text": "Location 2", "player": 1},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Reached entrance "},
|
|
{"type": "entrance_name", "text": "Entrance 2", "player": 1},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "color", "color": "green", "text": "True"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "color", "color": "salmon", "text": "False"},
|
|
{"type": "text", "text": ")"},
|
|
]
|
|
assert self.resolved_rule.explain_json(self.state) == expected
|
|
|
|
def test_explain_json_without_state(self) -> None:
|
|
expected: list[JSONMessagePart] = [
|
|
{"type": "text", "text": "("},
|
|
{"type": "text", "text": "("},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "4"},
|
|
{"type": "text", "text": "x "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 1", "player": 1},
|
|
{"type": "text", "text": " | "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "all"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "item_name", "flags": 1, "text": "Item 2", "player": 1},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 3", "player": 1},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " | "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "any"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "item_name", "flags": 1, "text": "Item 4", "player": 1},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 5", "player": 1},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "all"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "item_name", "flags": 1, "text": "Item 6", "player": 1},
|
|
{"type": "text", "text": " x1"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 7", "player": 1},
|
|
{"type": "text", "text": " x5"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "any"},
|
|
{"type": "text", "text": " of ("},
|
|
{"type": "item_name", "flags": 1, "text": "Item 8", "player": 1},
|
|
{"type": "text", "text": " x2"},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 9", "player": 1},
|
|
{"type": "text", "text": " x3"},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "2"},
|
|
{"type": "text", "text": "x items from ("},
|
|
{"type": "item_name", "flags": 1, "text": "Item 10", "player": 1},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 11", "player": 1},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 12", "player": 1},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "1"},
|
|
{"type": "text", "text": "x unique items from ("},
|
|
{"type": "item_name", "flags": 1, "text": "Item 13", "player": 1},
|
|
{"type": "text", "text": ", "},
|
|
{"type": "item_name", "flags": 1, "text": "Item 14", "player": 1},
|
|
{"type": "text", "text": ")"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "1"},
|
|
{"type": "text", "text": " items from "},
|
|
{"type": "color", "color": "cyan", "text": "Group 1"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Has "},
|
|
{"type": "color", "color": "cyan", "text": "2"},
|
|
{"type": "text", "text": " unique items from "},
|
|
{"type": "color", "color": "cyan", "text": "Group 2"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Can reach region "},
|
|
{"type": "color", "color": "yellow", "text": "Region 2"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Can reach location "},
|
|
{"type": "location_name", "text": "Location 2", "player": 1},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "text", "text": "Can reach entrance "},
|
|
{"type": "entrance_name", "text": "Entrance 2", "player": 1},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "color", "color": "green", "text": "True"},
|
|
{"type": "text", "text": " & "},
|
|
{"type": "color", "color": "salmon", "text": "False"},
|
|
{"type": "text", "text": ")"},
|
|
]
|
|
assert self.resolved_rule.explain_json() == expected
|
|
|
|
def test_explain_str_with_state_no_items(self) -> None:
|
|
expected = (
|
|
"((Missing 4x Item 1",
|
|
"| Missing some of (Missing: Item 2, Item 3)",
|
|
"| Missing all of (Missing: Item 4, Item 5))",
|
|
"& Missing some of (Missing: Item 6 x1, Item 7 x5)",
|
|
"& Missing all of (Missing: Item 8 x2, Item 9 x3)",
|
|
"& Has 0/2 items from (Missing: Item 10, Item 11, Item 12)",
|
|
"& Has 0/1 unique items from (Missing: Item 13, Item 14)",
|
|
"& Has 0/1 items from Group 1",
|
|
"& Has 0/2 unique items from Group 2",
|
|
"& Cannot reach region Region 2",
|
|
"& Cannot reach location Location 2",
|
|
"& Cannot reach entrance Entrance 2",
|
|
"& True",
|
|
"& False)",
|
|
)
|
|
assert self.resolved_rule.explain_str(self.state) == " ".join(expected)
|
|
|
|
def test_explain_str_with_state_all_items(self) -> None:
|
|
self._collect_all()
|
|
|
|
expected = (
|
|
"((Has 4x Item 1",
|
|
"| Has all of (Found: Item 2, Item 3)",
|
|
"| Has some of (Found: Item 4, Item 5))",
|
|
"& Has all of (Found: Item 6 x1, Item 7 x5)",
|
|
"& Has some of (Found: Item 8 x2, Item 9 x3)",
|
|
"& Has 30/2 items from (Found: Item 10, Item 11, Item 12)",
|
|
"& Has 2/1 unique items from (Found: Item 13, Item 14)",
|
|
"& Has 30/1 items from Group 1",
|
|
"& Has 2/2 unique items from Group 2",
|
|
"& Reached region Region 2",
|
|
"& Reached location Location 2",
|
|
"& Reached entrance Entrance 2",
|
|
"& True",
|
|
"& False)",
|
|
)
|
|
assert self.resolved_rule.explain_str(self.state) == " ".join(expected)
|
|
|
|
def test_explain_str_without_state(self) -> None:
|
|
expected = (
|
|
"((Has 4x Item 1",
|
|
"| Has all of (Item 2, Item 3)",
|
|
"| Has any of (Item 4, Item 5))",
|
|
"& Has all of (Item 6 x1, Item 7 x5)",
|
|
"& Has any of (Item 8 x2, Item 9 x3)",
|
|
"& Has 2x items from (Item 10, Item 11, Item 12)",
|
|
"& Has a unique item from (Item 13, Item 14)",
|
|
"& Has an item from Group 1",
|
|
"& Has 2x unique items from Group 2",
|
|
"& Can reach region Region 2",
|
|
"& Can reach location Location 2",
|
|
"& Can reach entrance Entrance 2",
|
|
"& True",
|
|
"& False)",
|
|
)
|
|
assert self.resolved_rule.explain_str() == " ".join(expected)
|
|
|
|
def test_str(self) -> None:
|
|
expected = (
|
|
"((Has 4x Item 1",
|
|
"| Has all of (Item 2, Item 3)",
|
|
"| Has any of (Item 4, Item 5))",
|
|
"& Has all of (Item 6 x1, Item 7 x5)",
|
|
"& Has any of (Item 8 x2, Item 9 x3)",
|
|
"& Has 2x items from (Item 10, Item 11, Item 12)",
|
|
"& Has a unique item from (Item 13, Item 14)",
|
|
"& Has an item from Group 1",
|
|
"& Has 2x unique items from Group 2",
|
|
"& Can reach region Region 2",
|
|
"& Can reach location Location 2",
|
|
"& Can reach entrance Entrance 2",
|
|
"& True",
|
|
"& False)",
|
|
)
|
|
assert str(self.resolved_rule) == " ".join(expected)
|