diff --git a/worlds/crosscode/codegen/ast.py b/worlds/crosscode/codegen/ast.py index bd88dc12a24d..7741fe8bbf90 100644 --- a/worlds/crosscode/codegen/ast.py +++ b/worlds/crosscode/codegen/ast.py @@ -5,22 +5,46 @@ import typing import ast -from ..types.condition import Condition +from ..types.condition import AndCondition, Condition, OrCondition, QuestCondition from ..types.locations import AccessInfo, LocationData from ..types.regions import Goal, RegionConnection from ..types.items import ItemData, ItemPoolEntry, ProgressiveChainEntry, SingleItemData from ..types.shops import ShopData +def create_expression_dataclass(cls) -> ast.Call: + result = ast.Call( + func=ast.Name(cls.__class__.__name__), + args=[], + keywords=[ + ast.keyword(arg=key, value=ast.Constant(value)) + for key, value in cls.__dict__.items() + if cls.__dataclass_fields__[key].init + ], + ) + ast.fix_missing_locations(result) + + return result + def create_expression_condition(condition: Condition) -> ast.Call: """ Create an expression representing a singular condition. """ - result = ast.Call( - func=ast.Name(condition.__class__.__name__), - args=[], - keywords=[ast.keyword(arg=key, value=ast.Constant(value)) for key, value in condition.__dict__.items()], - ) + if isinstance(condition, OrCondition) or isinstance(condition, AndCondition): + # we handle these conditions in a special way, since their lists cannot be encoded by ast.Constant + result = ast.Call( + func=ast.Name(condition.__class__.__name__), + args=[], + keywords=[ + ast.keyword( + arg="subconditions", + value=create_expression_condition_list(condition.subconditions) + ) + ], + ) + else: + # this block should handle most cases, unless you make a condition that has a complex type + result = create_expression_dataclass(condition) ast.fix_missing_locations(result) return result diff --git a/worlds/crosscode/logic.py b/worlds/crosscode/logic.py index 05811468b6f1..de7ce9e252c6 100644 --- a/worlds/crosscode/logic.py +++ b/worlds/crosscode/logic.py @@ -2,20 +2,23 @@ This module contains various logic functions """ +from __future__ import annotations import typing from BaseClasses import CollectionState -from .types.condition import Condition, LogicDict +from .types.condition import Condition + +if typing.TYPE_CHECKING: + from .world import CrossCodeWorld def condition_satisfied( player: int, conditions: list[Condition], location: int | None, - cond_args: LogicDict + world: CrossCodeWorld ) -> typing.Callable[[CollectionState], bool]: """ Factory function. Return value is a rule that checks whether all the conditions are satisfied. """ - def conditions_satisfied_internal(state: CollectionState) -> bool: - return all(c.satisfied(state, player, location, cond_args) for c in conditions) + callbacks = [c.satisfied(player, location, world) for c in conditions] - return conditions_satisfied_internal + return lambda state: all(map(lambda x: x(state), callbacks)) diff --git a/worlds/crosscode/types/condition.py b/worlds/crosscode/types/condition.py index 4a3945d4342f..4f74c13d0b8d 100644 --- a/worlds/crosscode/types/condition.py +++ b/worlds/crosscode/types/condition.py @@ -1,29 +1,19 @@ +from __future__ import annotations import typing import abc -from dataclasses import field, dataclass +from dataclasses import dataclass, field from BaseClasses import CollectionState from ..options import ShopReceiveMode from .items import ItemPoolEntry -class LogicDict(typing.TypedDict): - mode: str - variables: dict[str, list[str]] - variable_definitions: dict[str, dict[str, list["Condition"]]] - keyrings: set[str] - item_progressive_replacements: dict[str, list[tuple[str, int]]] - chest_clearance_levels: dict[int, str] - shop_receive_mode: int | None - shop_unlock_by_id: dict[int, ItemPoolEntry] - shop_unlock_by_shop: dict[str, ItemPoolEntry] - shop_unlock_by_shop_and_id: dict[tuple[str, int], ItemPoolEntry] - region_botanics_amounts: dict[str, int] - botanics_completion_amount: int +if typing.TYPE_CHECKING: + from ..world import CrossCodeWorld class Condition(abc.ABC): @abc.abstractmethod - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: pass @dataclass @@ -31,61 +21,69 @@ class ItemCondition(Condition): item_name: str amount: int = 1 - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: target = self.amount - if self.item_name in args["keyrings"]: + if self.item_name in world.keyrings: target = 1 - replacements = args["item_progressive_replacements"] + replacements = world.pools.item_progressive_replacements if self.item_name in replacements: - for prog_item_name, quantity in replacements[self.item_name]: - if state.has(prog_item_name, player, quantity): - return True + def callback(state: CollectionState) -> bool: + for prog_item_name, quantity in replacements[self.item_name]: + if state.has(prog_item_name, player, quantity): + return True + return state.has(self.item_name, player, target) - return state.has(self.item_name, player, target) + return callback + + return lambda state: state.has(self.item_name, player, target) @dataclass class QuestCondition(Condition): quest_name: str + event_name: str = field(init=False) + + def __post_init__(self): + self.event_name = f"{self.quest_name} (Event)" - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - return state.has(f"{self.quest_name} (Event)", player) + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + return lambda state: state.has(self.event_name, player) @dataclass class LocationCondition(Condition): location_name: str + event_name: str = field(init=False) + + def __post_init__(self): + self.event_name = f"{self.location_name} (Event)" - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - return state.has(f"{self.location_name} (Event)", player) + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + return lambda state: state.has(self.event_name, player) @dataclass class RegionCondition(Condition): target_mode: typing.Optional[str] region_name: str - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: # target_mode == None means that it matches in all modes. # so if target_mode == None, check if we can reach that region. # else, if the target mode matches, also check if we can reach that region. # otherwise, if the target mode does not match, return true (assume that it's being ANDed with other conditions) - mode: str = args["mode"] + mode: str = world.logic_mode if self.target_mode is None or mode == self.target_mode: - # BAD BAD BAD - # This will check *every time* this condition is called whether the region exists. - # I'm only doing this because I know I'm going to optimize it later. - # If we still have to check region existence in the refactor we have to calculate it early. - if self.region_name not in state.multiworld.regions.region_cache[player]: - return False - return state.can_reach_region(self.region_name, player) + if self.region_name not in world.multiworld.regions.region_cache[player]: + return lambda _: False + return lambda state: state.can_reach_region(self.region_name, player) - return True + return lambda _: True @dataclass class AnyElementCondition(Condition): - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - return any([ + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + return lambda state: any([ state.has("Heat", player), state.has("Cold", player), state.has("Shock", player), @@ -96,32 +94,40 @@ def satisfied(self, state: CollectionState, player: int, location: int | None, a class OrCondition(Condition): subconditions: list[Condition] - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - return any(map(lambda x: x.satisfied(state, player, location, args), self.subconditions)) + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + callbacks = [x.satisfied(player, location, world) for x in self.subconditions] + return lambda state: any(map(lambda x: x(state), callbacks)) @dataclass class AndCondition(Condition): subconditions: list[Condition] - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - return all(map(lambda x: x.satisfied(state, player, location, args), self.subconditions)) + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + callbacks = [x.satisfied(player, location, world) for x in self.subconditions] + return lambda state: all(map(lambda x: x(state), callbacks)) @dataclass class VariableCondition(Condition): name: str - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - variables = args["variables"] - variable_definitions = args["variable_definitions"] + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + variables = world.variables + variable_definitions = world.world_data.variable_definitions if self.name not in variables: - return True + return lambda _: True - for value in variables[self.name]: - if not all(map(lambda c: c.satisfied(state, player, location, args), variable_definitions[self.name][value])): - return False + callbacks = sum( + [ + [ + x.satisfied(player, location, world) + for x in variable_definitions[self.name][value] + ] for value in variables[self.name] + ], + start=[] + ) - return True + return lambda state: all(map(lambda x: x(state), callbacks)) @dataclass class VariableEntryCondition(Condition): @@ -129,10 +135,9 @@ class VariableEntryCondition(Condition): value: str desired: bool - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - variables = args["variables"] - - return (self.value in variables[self.name]) == self.desired + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + valid = (self.value in world.variables[self.name]) == self.desired + return lambda _: valid @dataclass class ChestKeyCondition(Condition): @@ -144,8 +149,8 @@ class ChestKeyCondition(Condition): "Gold": "Radiant Key", } - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - chest_levels = args["chest_clearance_levels"] + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + chest_levels = world.chest_clearance_levels if location is None: raise RuntimeError("An event cannot have a chest key condition") @@ -153,44 +158,50 @@ def satisfied(self, state: CollectionState, player: int, location: int | None, a level: str = chest_levels.get(location, self.default_level) if level == "Default": - return True + return lambda _: True - return state.has(ChestKeyCondition.clearance_items[level], player) + return lambda state: state.has(ChestKeyCondition.clearance_items[level], player) @dataclass class ShopSlotCondition(Condition): shop_name: str item_id: int - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - if args["shop_receive_mode"] is None: - return True - if args["shop_receive_mode"] == ShopReceiveMode.option_per_item_type: - return state.has(args["shop_unlock_by_id"][self.item_id].item.name, player) - if args["shop_receive_mode"] == ShopReceiveMode.option_per_shop: - return state.has(args["shop_unlock_by_shop"][self.shop_name].item.name, player) - if args["shop_receive_mode"] == ShopReceiveMode.option_per_slot: - return state.has(args["shop_unlock_by_shop_and_id"][self.shop_name, self.item_id].item.name, player) - return True + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + if world.shop_receive_mode is None: + return lambda _: True + if world.shop_receive_mode == ShopReceiveMode.option_per_item_type: + return lambda state: state.has(world.world_data.shop_unlock_by_id[self.item_id].item.name, player) + if world.shop_receive_mode == ShopReceiveMode.option_per_shop: + return lambda state: state.has(world.world_data.shop_unlock_by_shop[self.shop_name].item.name, player) + if world.shop_receive_mode == ShopReceiveMode.option_per_slot: + return lambda state: state.has(world.world_data.shop_unlock_by_shop_and_id[self.shop_name, self.item_id].item.name, player) + return lambda _: True @dataclass class BotanicsCompletionCondition(Condition): amount: float - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - collected = sum([ - amount - for region, amount in args["region_botanics_amounts"].items() - # See the other comment beginning with "BAD BAD BAD" -- this is bad for the same reason - if region in state.multiworld.regions.region_cache[player] - and state.can_reach_region(region, player) - ]) + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + regions = { + region: amount + for region, amount in world.world_data.region_botanics_amounts[world.logic_mode].items() + if region in world.multiworld.regions.region_cache[player] + } + + def satisfied_internal(state: CollectionState): + collected = sum([ + amount + for region, amount in regions.items() + if state.can_reach_region(region, player) + ]) - return collected / args["botanics_completion_amount"] >= self.amount + return collected / world.options.botanics_completion_amount >= self.amount + return satisfied_internal class NeverCondition(Condition): - def satisfied(self, state: CollectionState, player: int, location: int | None, args: LogicDict) -> bool: - return False + def satisfied(self, player: int, location: int | None, world: CrossCodeWorld) -> typing.Callable[[CollectionState], bool]: + return lambda _: False __all__ = [ "Condition", diff --git a/worlds/crosscode/world.py b/worlds/crosscode/world.py index 158d3ccc2f2f..f1a2184334be 100644 --- a/worlds/crosscode/world.py +++ b/worlds/crosscode/world.py @@ -1,5 +1,4 @@ """ - This module contains the world class for CrossCode. """ @@ -21,7 +20,7 @@ from .types.items import ItemData, CrossCodeItem from .types.locations import CrossCodeLocation, LocationData -from .types.condition import LogicDict, Condition, LocationCondition +from .types.condition import Condition, LocationCondition from .types.world import WorldData from .types.regions import RegionsData from .types.metadata import IncludeOptions @@ -97,6 +96,10 @@ class CrossCodeWorld(World): rhombus_hub_unlock: bool + keyrings: set[str] + shop_receive_mode: int | None + chest_clearance_levels: dict[int, str] + pre_fill_specific_dungeons_names: dict[str, set[str]] pre_fill_any_dungeon_names: set[str] @@ -106,8 +109,6 @@ class CrossCodeWorld(World): dungeon_location_list: dict[str, set[CrossCodeLocation]] dungeon_areas: typing.ClassVar[set[str]] = {"cold-dng", "heat-dng", "shock-dng", "wave-dng", "tree-dng", "final-dng"} - logic_dict: LogicDict - location_events: dict[str, Location] variables: dict[str, list[str]] @@ -421,27 +422,16 @@ def generate_early(self): self.pre_fill_any_dungeon_names ) - self.logic_dict: LogicDict = { - "mode": self.logic_mode, - "variables": self.variables, - "variable_definitions": self.world_data.variable_definitions, - "keyrings": self.world_data.keyring_items if self.options.keyrings.value else set(), - "item_progressive_replacements": self.pools.item_progressive_replacements, - "chest_clearance_levels": {}, - "shop_receive_mode": self.options.shop_receive_mode.value if self.options.shop_rando.value else None, - "shop_unlock_by_id": self.world_data.shop_unlock_by_id, - "shop_unlock_by_shop": self.world_data.shop_unlock_by_shop, - "shop_unlock_by_shop_and_id": self.world_data.shop_unlock_by_shop_and_id, - "region_botanics_amounts": self.world_data.region_botanics_amounts[self.logic_mode], - "botanics_completion_amount": self.options.botanics_completion_amount.value, - } + self.keyrings = self.world_data.keyring_items if self.options.keyrings.value else set() + self.shop_receive_mode = self.options.shop_receive_mode.value if self.options.shop_rando.value else None + self.chest_clearance_levels = {} # Universal Tracker support # Anything that is generated in a non-standard fashion on my end has to be brought back into scope here from slot data. if hasattr(self.multiworld, "re_gen_passthrough"): slot_data: SlotData = self.multiworld.re_gen_passthrough["CrossCode"] # Reinterpret the JSON chest clearance levels dict (a string -> string mapping) as an int -> int mapping instead. - self.logic_dict["chest_clearance_levels"] = {int(combo_id): clearance for combo_id, clearance in slot_data["options"]["chestClearanceLevels"].items()} + self.chest_clearance_levels = {int(combo_id): clearance for combo_id, clearance in slot_data["options"]["chestClearanceLevels"].items()} @classmethod def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: set[int]) -> World: @@ -478,7 +468,7 @@ def add_location(self, data: LocationData, region: Region): cum_weights=self._chest_lock_weights )[0] - self.logic_dict["chest_clearance_levels"][data.code] = clearance + self.chest_clearance_levels[data.code] = clearance def create_shops(self): # don't filter the shop pool - the regions must always exist to prevent generation errors @@ -492,7 +482,7 @@ def create_shops(self): self.region_dict[from_region].connect( region, f"{from_region} => {shop_name}", - condition_satisfied(self.player, shop.access.cond, None, self.logic_dict) if shop.access.cond else None + condition_satisfied(self.player, shop.access.cond, None, self) if shop.access.cond else None ) if self.options.shop_send_mode == ShopSendMode.option_per_slot: @@ -517,7 +507,7 @@ def create_regions(self): self.region_dict[conn.region_from].connect( self.region_dict[conn.region_to], f"{conn.region_from} => {conn.region_to}", - condition_satisfied(self.player, conn.cond, None, self.logic_dict) if conn.cond is not None else None + condition_satisfied(self.player, conn.cond, None, self) if conn.cond is not None else None ) self.create_event_conditions(conn.cond) @@ -565,7 +555,7 @@ def create_regions(self): self.player, goal.condition if goal.condition is not None else [], None, - self.logic_dict + self ) ) self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) @@ -656,7 +646,7 @@ def set_rules(self): if not isinstance(loc, CrossCodeLocation): continue if loc.data.access.cond is not None: - add_rule(loc, condition_satisfied(self.player, loc.data.access.cond, loc.data.code, self.logic_dict)) + add_rule(loc, condition_satisfied(self.player, loc.data.access.cond, loc.data.code, self)) def get_pre_fill_items(self) -> list[Item]: pre_fill_items = self.pre_fill_any_dungeon.copy() @@ -761,7 +751,7 @@ def priority(item: CrossCodeItem) -> int: def extend_hint_information(self, hint_data: dict[int, dict[int, str]]): ours = hint_data.setdefault(self.player, {}) - for loc, clearance in self.logic_dict["chest_clearance_levels"].items(): + for loc, clearance in self.chest_clearance_levels.items(): ours[loc] = clearance def fill_slot_data(self) -> SlotData: @@ -796,7 +786,7 @@ def fill_slot_data(self) -> SlotData: "meteorPassage": bool(self.options.vw_meteor_passage.value), "closedGaia": self.options.closed_gaia.value, "vtSkip": bool(self.options.vt_skip.value), - "keyrings": [self.world_data.single_items_dict[name].item_id for name in self.logic_dict["keyrings"]], + "keyrings": [self.world_data.single_items_dict[name].item_id for name in self.keyrings], "chestReveal": bool(self.options.chest_reveal.value), "allowBoosterGrinding": bool(self.options.allow_booster_grinding), "questRando": bool(self.options.quest_rando.value), @@ -807,7 +797,7 @@ def fill_slot_data(self) -> SlotData: "shopSendMode": shop_send_mode_string, "shopReceiveMode": shop_receive_mode_string, "shopDialogHints": bool(self.options.shop_dialog_hints.value), - "chestClearanceLevels": self.logic_dict["chest_clearance_levels"], + "chestClearanceLevels": self.chest_clearance_levels, "botanicsCompletionAmount": self.options.botanics_completion_amount.value, } }