diff --git a/BaseClasses.py b/BaseClasses.py index aed336a24e09..33dbe8d1a36b 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -3,13 +3,13 @@ import copy import itertools -from argparse import Namespace from enum import unique, IntEnum, IntFlag import logging import json import functools from collections import OrderedDict, Counter, deque -from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple +from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple, Type, \ + get_type_hints import typing # this can go away when Python 3.8 support is dropped import secrets import random @@ -214,8 +214,10 @@ def set_options(self, args: Namespace) -> None: world_type.option_definitions): option_values = getattr(args, option_key, {}) setattr(self, option_key, option_values) - if player in option_values: - self.worlds[player].options[option_key] = option_values[player] + # TODO - remove this loop once all worlds use options dataclasses + options_dataclass: Type[Options.GameOptions] = self.worlds[player].options_dataclass + self.worlds[player].o = options_dataclass(**{option_key: getattr(args, option_key)[player] + for option_key in get_type_hints(options_dataclass)}) def set_item_links(self): item_links = {} diff --git a/Fill.py b/Fill.py index cb8d2a878741..2d6647dea603 100644 --- a/Fill.py +++ b/Fill.py @@ -55,7 +55,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.worlds[item_to_place.player].options["accessibility"] == Accessibility.option_minimal: + if world.worlds[item_to_place.player].o.accessibility == Accessibility.option_minimal: perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game diff --git a/Options.py b/Options.py index ad87f5ebf8d9..3876e59c324c 100644 --- a/Options.py +++ b/Options.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc from copy import deepcopy +from dataclasses import dataclass import math import numbers import typing @@ -862,10 +863,13 @@ class ProgressionBalancing(SpecialRange): } -common_options = { - "progression_balancing": ProgressionBalancing, - "accessibility": Accessibility -} +@dataclass +class CommonOptions: + progression_balancing: ProgressionBalancing + accessibility: Accessibility + +common_options = typing.get_type_hints(CommonOptions) +# TODO - remove this dict once all worlds use options dataclasses class ItemSet(OptionSet): @@ -982,18 +986,24 @@ def verify(self, world, player_name: str, plando_options) -> None: raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.") -per_game_common_options = { - **common_options, # can be overwritten per-game - "local_items": LocalItems, - "non_local_items": NonLocalItems, - "early_items": EarlyItems, - "start_inventory": StartInventory, - "start_hints": StartHints, - "start_location_hints": StartLocationHints, - "exclude_locations": ExcludeLocations, - "priority_locations": PriorityLocations, - "item_links": ItemLinks -} +@dataclass +class PerGameCommonOptions(CommonOptions): + local_items: LocalItems + non_local_items: NonLocalItems + early_items: EarlyItems + start_inventory: StartInventory + start_hints: StartHints + start_location_hints: StartLocationHints + exclude_locations: ExcludeLocations + priority_locations: PriorityLocations + item_links: ItemLinks + +per_game_common_options = typing.get_type_hints(PerGameCommonOptions) +# TODO - remove this dict once all worlds use options dataclasses + + +GameOptions = typing.TypeVar("GameOptions", bound=PerGameCommonOptions) + if __name__ == "__main__": diff --git a/docs/world api.md b/docs/world api.md index 5cc7f9148195..6733ca093e0d 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -85,9 +85,10 @@ inside a World object. ### Player Options Players provide customized settings for their World in the form of yamls. -Those are accessible through `self.options[""]`. A dict -of valid options has to be provided in `self.option_definitions`. Options are automatically -added to the `World` object for easy access. +A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`. +(It must be a subclass of `PerGameCommonOptions`.) +Option results are automatically added to the `World` object for easy access. +Those are accessible through `self.o.`. ### World Options @@ -209,11 +210,11 @@ See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requireme AP will only import the `__init__.py`. Depending on code size it makes sense to use multiple files and use relative imports to access them. -e.g. `from .Options import mygame_options` from your `__init__.py` will load -`world/[world_name]/Options.py` and make its `mygame_options` accessible. +e.g. `from .Options import MyGameOptions` from your `__init__.py` will load +`world/[world_name]/Options.py` and make its `MyGameOptions` accessible. When imported names pile up it may be easier to use `from . import Options` -and access the variable as `Options.mygame_options`. +and access the variable as `Options.MyGameOptions`. Imports from directories outside your world should use absolute imports. Correct use of relative / absolute imports is required for zipped worlds to @@ -261,9 +262,9 @@ Each option has its own class, inherits from a base option type, has a docstring to describe it and a `display_name` property for display on the website and in spoiler logs. -The actual name as used in the yaml is defined in a `dict[str, Option]`, that is -assigned to the world under `self.option_definitions`. By convention, the string -that defines your option should be in `snake_case`. +The actual name as used in the yaml is defined via the field names of a `dataclass` that is +assigned to the world under `self.options_dataclass`. By convention, the strings +that define your option names should be in `snake_case`. Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`. For more see `Options.py` in AP's base directory. @@ -298,8 +299,8 @@ default = 0 ```python # Options.py -from Options import Toggle, Range, Choice, Option -import typing +from dataclasses import dataclass +from Options import Toggle, Range, Choice, PerGameCommonOptions class Difficulty(Choice): """Sets overall game difficulty.""" @@ -322,25 +323,26 @@ class FixXYZGlitch(Toggle): """Fixes ABC when you do XYZ""" display_name = "Fix XYZ Glitch" -# By convention we call the options dict variable `_options`. -mygame_options: typing.Dict[str, type(Option)] = { - "difficulty": Difficulty, - "final_boss_hp": FinalBossHP, - "fix_xyz_glitch": FixXYZGlitch -} +# By convention, we call the options dataclass `Options`. +@dataclass +class MyGameOptions(PerGameCommonOptions): + difficulty: Difficulty + final_boss_hp: FinalBossHP + fix_xyz_glitch: FixXYZGlitch ``` ```python # __init__.py from worlds.AutoWorld import World -from .Options import mygame_options # import the options dict +from .Options import MyGameOptions # import the options dataclass class MyGameWorld(World): #... - option_definitions = mygame_options # assign the options dict to the world + options_dataclass = MyGameOptions # assign the options dataclass to the world + o: MyGameOptions # typing for option results #... ``` - + ### Local or Remote A world with `remote_items` set to `True` gets all items items from the server @@ -358,7 +360,7 @@ more natural. These games typically have been edited to 'bake in' the items. ```python # world/mygame/__init__.py -from .Options import mygame_options # the options we defined earlier +from .Options import MyGameOptions # the options we defined earlier from .Items import mygame_items # data used below to add items to the World from .Locations import mygame_locations # same as above from worlds.AutoWorld import World @@ -374,7 +376,8 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation class MyGameWorld(World): """Insert description of the world/game here.""" game: str = "My Game" # name of the game/world - option_definitions = mygame_options # options the player can set + options_dataclass = MyGameOptions # options the player can set + o: MyGameOptions # typing for option results topology_present: bool = True # show path to required location checks in spoiler remote_items: bool = False # True if all items come from the server remote_start_inventory: bool = False # True if start inventory comes from the server @@ -456,7 +459,7 @@ In addition, the following methods can be implemented and attributes can be set ```python def generate_early(self) -> None: # read player settings to world instance - self.final_boss_hp = self.options["final_boss_hp"].value + self.final_boss_hp = self.o.final_boss_hp.value ``` #### create_item @@ -676,9 +679,9 @@ def generate_output(self, output_directory: str): in self.world.precollected_items[self.player]], "final_boss_hp": self.final_boss_hp, # store option name "easy", "normal" or "hard" for difficuly - "difficulty": self.options["difficulty"].current_key, + "difficulty": self.o.difficulty.current_key, # store option value True or False for fixing a glitch - "fix_xyz_glitch": self.options["fix_xyz_glitch"].value + "fix_xyz_glitch": self.o.fix_xyz_glitch.value } # point to a ROM specified by the installation src = Utils.get_options()["mygame_options"]["rom_file"] diff --git a/test/general/TestFill.py b/test/general/TestFill.py index 2ccc5430df72..ad8261ee8f2c 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -1,5 +1,5 @@ import itertools -from typing import List, Iterable +from typing import get_type_hints, List, Iterable import unittest import Options @@ -28,7 +28,9 @@ def generate_multi_world(players: int = 1) -> MultiWorld: for option_key in itertools.chain(Options.common_options, Options.per_game_common_options): option_value = getattr(args, option_key, {}) setattr(multi_world, option_key, option_value) - multi_world.worlds[player_id].options[option_key] = option_value[player_id] + # TODO - remove this loop once all worlds use options dataclasses + world.o = world.options_dataclass(**{option_key: getattr(args, option_key)[player_id] + for option_key in get_type_hints(world.options_dataclass)}) multi_world.set_seed(0) @@ -196,7 +198,7 @@ def test_minimal_fill(self): items = player1.prog_items locations = player1.locations - multi_world.worlds[player1.id].options["accessibility"] = Accessibility.from_any(Accessibility.option_minimal) + multi_world.worlds[player1.id].o.accessibility = Accessibility.from_any(Accessibility.option_minimal) multi_world.completion_condition[player1.id] = lambda state: state.has( items[1].name, player1.id) set_rule(locations[1], lambda state: state.has( diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 943c25b59f3f..6f0c2029abd7 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -5,7 +5,7 @@ import pathlib from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING -from Options import AssembleOptions, Option +from Options import AssembleOptions, GameOptions, PerGameCommonOptions from BaseClasses import CollectionState if TYPE_CHECKING: @@ -130,8 +130,10 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - option_definitions: Dict[str, AssembleOptions] = {} # link your Options mapping - options: Dict[str, Option[Any]] # automatically populated option names to resulting option object + option_definitions: Dict[str, AssembleOptions] = {} # TODO - remove this once all worlds use options dataclasses + options_dataclass: Type[GameOptions] = PerGameCommonOptions # link your Options mapping + o: PerGameCommonOptions + game: str # name the game topology_present: bool = False # indicate if world type has any meaningful layout/pathing @@ -199,7 +201,6 @@ class World(metaclass=AutoWorldRegister): def __init__(self, world: "MultiWorld", player: int): self.world = world self.player = player - self.options = {} # overridable methods that get called by Main.py, sorted by execution order # can also be implemented as a classmethod and called "stage_", diff --git a/worlds/ror2/Options.py b/worlds/ror2/Options.py index a95cbf597ae4..fd61b951d3d7 100644 --- a/worlds/ror2/Options.py +++ b/worlds/ror2/Options.py @@ -1,5 +1,5 @@ -from typing import Dict -from Options import Option, DefaultOnToggle, Range, Choice +from dataclasses import dataclass +from Options import DefaultOnToggle, Range, Choice, PerGameCommonOptions class TotalLocations(Range): @@ -150,28 +150,28 @@ class ItemWeights(Choice): option_scraps_only = 8 -# define a dictionary for the weights of the generated item pool. -ror2_weights: Dict[str, type(Option)] = { - "green_scrap": GreenScrap, - "red_scrap": RedScrap, - "yellow_scrap": YellowScrap, - "white_scrap": WhiteScrap, - "common_item": CommonItem, - "uncommon_item": UncommonItem, - "legendary_item": LegendaryItem, - "boss_item": BossItem, - "lunar_item": LunarItem, - "equipment": Equipment -} - -ror2_options: Dict[str, type(Option)] = { - "total_locations": TotalLocations, - "total_revivals": TotalRevivals, - "start_with_revive": StartWithRevive, - "final_stage_death": FinalStageDeath, - "item_pickup_step": ItemPickupStep, - "enable_lunar": AllowLunarItems, - "item_weights": ItemWeights, - "item_pool_presets": ItemPoolPresetToggle, - **ror2_weights -} +# define a class for the weights of the generated item pool. +@dataclass +class ROR2Weights: + green_scrap: GreenScrap + red_scrap: RedScrap + yellow_scrap: YellowScrap + white_scrap: WhiteScrap + common_item: CommonItem + uncommon_item: UncommonItem + legendary_item: LegendaryItem + boss_item: BossItem + lunar_item: LunarItem + equipment: Equipment + + +@dataclass +class ROR2Options(PerGameCommonOptions, ROR2Weights): + total_locations: TotalLocations + total_revivals: TotalRevivals + start_with_revive: StartWithRevive + final_stage_death: FinalStageDeath + item_pickup_step: ItemPickupStep + enable_lunar: AllowLunarItems + item_weights: ItemWeights + item_pool_presets: ItemPoolPresetToggle diff --git a/worlds/ror2/__init__.py b/worlds/ror2/__init__.py index bcc9f174dc0e..c420f3e7bd26 100644 --- a/worlds/ror2/__init__.py +++ b/worlds/ror2/__init__.py @@ -1,11 +1,11 @@ import string -from typing import Dict, List +from typing import Dict, get_type_hints, List from .Items import RiskOfRainItem, item_table, item_pool_weights from .Locations import RiskOfRainLocation, item_pickups from .Rules import set_rules from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial -from .Options import ror2_options, ItemWeights +from .Options import ItemWeights, ROR2Options from worlds.AutoWorld import World, WebWorld client_version = 1 @@ -29,7 +29,9 @@ class RiskOfRainWorld(World): first crash landing. """ game: str = "Risk of Rain 2" - option_definitions = ror2_options + option_definitions = get_type_hints(ROR2Options) + options_dataclass = ROR2Options + o: ROR2Options topology_present = False item_name_to_id = item_table @@ -42,17 +44,17 @@ class RiskOfRainWorld(World): def generate_early(self) -> None: # figure out how many revivals should exist in the pool - self.total_revivals = int(self.options["total_revivals"].value // 100 * self.options["total_locations"].value) + self.total_revivals = int(self.o.total_revivals.value // 100 * self.o.total_locations.value) def generate_basic(self) -> None: # shortcut for starting_inventory... The start_with_revive option lets you start with a Dio's Best Friend - if self.options["start_with_revive"].value: + if self.o.start_with_revive.value: self.world.push_precollected(self.world.create_item("Dio's Best Friend", self.player)) # if presets are enabled generate junk_pool from the selected preset - pool_option = self.options["item_weights"].value + pool_option = self.o.item_weights.value junk_pool: Dict[str, int] = {} - if self.options["item_pool_presets"]: + if self.o.item_pool_presets: # generate chaos weights if the preset is chosen if pool_option == ItemWeights.option_chaos: for name, max_value in item_pool_weights[pool_option].items(): @@ -61,20 +63,20 @@ def generate_basic(self) -> None: junk_pool = item_pool_weights[pool_option].copy() else: # generate junk pool from user created presets junk_pool = { - "Item Scrap, Green": self.options["green_scrap"].value, - "Item Scrap, Red": self.options["red_scrap"].value, - "Item Scrap, Yellow": self.options["yellow_scrap"].value, - "Item Scrap, White": self.options["white_scrap"].value, - "Common Item": self.options["common_item"].value, - "Uncommon Item": self.options["uncommon_item"].value, - "Legendary Item": self.options["legendary_item"].value, - "Boss Item": self.options["boss_item"].value, - "Lunar Item": self.options["lunar_item"].value, - "Equipment": self.options["equipment"].value + "Item Scrap, Green": self.o.green_scrap.value, + "Item Scrap, Red": self.o.red_scrap.value, + "Item Scrap, Yellow": self.o.yellow_scrap.value, + "Item Scrap, White": self.o.white_scrap.value, + "Common Item": self.o.common_item.value, + "Uncommon Item": self.o.uncommon_item.value, + "Legendary Item": self.o.legendary_item.value, + "Boss Item": self.o.boss_item.value, + "Lunar Item": self.o.lunar_item.value, + "Equipment": self.o.equipment.value } # remove lunar items from the pool if they're disabled in the yaml unless lunartic is rolled - if not (self.options["enable_lunar"] or pool_option == ItemWeights.option_lunartic): + if not (self.o.enable_lunar or pool_option == ItemWeights.option_lunartic): junk_pool.pop("Lunar Item") # Generate item pool @@ -84,7 +86,7 @@ def generate_basic(self) -> None: # Fill remaining items with randomly generated junk itempool += self.world.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), - k=self.options["total_locations"].value - self.total_revivals) + k=self.o.total_locations.value - self.total_revivals) # Convert itempool into real items itempool = list(map(lambda name: self.create_item(name), itempool)) @@ -97,7 +99,7 @@ def set_rules(self) -> None: def create_regions(self) -> None: menu = create_region(self.world, self.player, "Menu") petrichor = create_region(self.world, self.player, "Petrichor V", - [f"ItemPickup{i + 1}" for i in range(self.options["total_locations"].value)]) + [f"ItemPickup{i + 1}" for i in range(self.o.total_locations.value)]) connection = Entrance(self.player, "Lobby", menu) menu.exits.append(connection) @@ -109,12 +111,12 @@ def create_regions(self) -> None: def fill_slot_data(self): return { - "itemPickupStep": self.options["item_pickup_step"].value, + "itemPickupStep": self.o.item_pickup_step.value, "seed": "".join(self.world.slot_seeds[self.player].choice(string.digits) for _ in range(16)), - "totalLocations": self.options["total_locations"].value, - "totalRevivals": self.options["total_revivals"].value, - "startWithDio": self.options["start_with_revive"].value, - "FinalStageDeath": self.options["final_stage_death"].value + "totalLocations": self.o.total_locations.value, + "totalRevivals": self.o.total_revivals.value, + "startWithDio": self.o.start_with_revive.value, + "FinalStageDeath": self.o.final_stage_death.value } def create_item(self, name: str) -> Item: @@ -129,7 +131,7 @@ def create_item(self, name: str) -> Item: return item def create_events(self) -> None: - total_locations = self.options["total_locations"].value + total_locations = self.o.total_locations.value num_of_events = total_locations // 25 if total_locations / 25 == num_of_events: num_of_events -= 1