Skip to content

Commit c686c1f

Browse files
alwaysintrebleel-ubeauxqblack-sliveragilbert1412
authored andcommitted
Core: move option results to the World class instead of MultiWorld (ArchipelagoMW#993)
🤞 * map option objects to a `World.options` dict * convert RoR2 to options dict system for testing * add temp behavior for lttp with notes * copy/paste bad * convert `set_default_common_options` to a namespace property * reorganize test call order * have fill_restrictive use the new options system * update world api * update soe tests * fix world api * core: auto initialize a dataclass on the World class with the option results * core: auto initialize a dataclass on the World class with the option results: small tying improvement * add `as_dict` method to the options dataclass * fix namespace issues with tests * have current option updates use `.value` instead of changing the option * update ror2 to use the new options system again * revert the junk pool dict since it's cased differently * fix begin_with_loop typo * write new and old options to spoiler * change factorio option behavior back * fix comparisons * move common and per_game_common options to new system * core: automatically create missing options_dataclass from legacy option_definitions * remove spoiler special casing and add back the Factorio option changing but in new system * give ArchipIDLE the default options_dataclass so its options get generated and spoilered properly * reimplement `inspect.get_annotations` * move option info generation for webhost to new system * need to include Common and PerGame common since __annotations__ doesn't include super * use get_type_hints for the options dictionary * typing.get_type_hints returns the bases too. * forgot to sweep through generate * sweep through all the tests * swap to a metaclass property * move remaining usages from get_type_hints to metaclass property * move remaining usages from __annotations__ to metaclass property * move remaining usages from legacy dictionaries to metaclass property * remove legacy dictionaries * cache the metaclass property * clarify inheritance in world api * move the messenger to new options system * add an assert for my dumb * update the doc * rename o to options * missed a spot * update new messenger options * comment spacing Co-authored-by: Doug Hoskisson <[email protected]> * fix tests * fix missing import * make the documentation definition more accurate * use options system for loc creation * type cast MessengerWorld * fix typo and use quotes for cast * LTTP: set random seed in tests * ArchipIdle: remove change here as it's default on AutoWorld * Stardew: Need to set state because `set_default_common_options` used to * The Messenger: update shop rando and helpers to new system; optimize imports * Add a kwarg to `as_dict` to do the casing for you * RoR2: use new kwarg for less code * RoR2: revert some accidental reverts * The Messenger: remove an unnecessary variable * remove TypeVar that isn't used * CommonOptions not abstract * Docs: fix mistake in options api.md Co-authored-by: Doug Hoskisson <[email protected]> * create options for item link worlds * revert accidental doc removals * Item Links: set default options on group * change Zillion to new options dataclass * remove unused parameter to function * use TypeGuard for Literal narrowing * move dlc quest to new api * move overcooked 2 to new api * fixed some missed code in oc2 * - Tried to be compliant with 993 (WIP?) * - I think it all works now * - Removed last trace of me touching core * typo * It now passes all tests! * Improve options, fix all issues I hope * - Fixed init options * dlcquest: fix bad imports * missed a file * - Reduce code duplication * add as_dict documentation * - Use .items(), get option name more directly, fix slot data content * - Remove generic options from the slot data * improve slot data documentation * remove `CommonOptions.get_value` (ArchipelagoMW#21) * better slot data description Co-authored-by: black-sliver <[email protected]> --------- Co-authored-by: el-u <[email protected]> Co-authored-by: Doug Hoskisson <[email protected]> Co-authored-by: Doug Hoskisson <[email protected]> Co-authored-by: black-sliver <[email protected]> Co-authored-by: Alex Gilbert <[email protected]>
1 parent 5dcf5aa commit c686c1f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+1591
-1607
lines changed

BaseClasses.py

+15-25
Original file line numberDiff line numberDiff line change
@@ -226,25 +226,24 @@ def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optio
226226
range(1, self.players + 1)}
227227

228228
def set_options(self, args: Namespace) -> None:
229-
for option_key in Options.common_options:
230-
setattr(self, option_key, getattr(args, option_key, {}))
231-
for option_key in Options.per_game_common_options:
232-
setattr(self, option_key, getattr(args, option_key, {}))
233-
234229
for player in self.player_ids:
235230
self.custom_data[player] = {}
236231
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
237-
for option_key in world_type.option_definitions:
238-
setattr(self, option_key, getattr(args, option_key, {}))
239-
240232
self.worlds[player] = world_type(self, player)
241233
self.worlds[player].random = self.per_slot_randoms[player]
234+
for option_key in world_type.options_dataclass.type_hints:
235+
option_values = getattr(args, option_key, {})
236+
setattr(self, option_key, option_values)
237+
# TODO - remove this loop once all worlds use options dataclasses
238+
options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass
239+
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
240+
for option_key in options_dataclass.type_hints})
242241

243242
def set_item_links(self):
244243
item_links = {}
245244
replacement_prio = [False, True, None]
246245
for player in self.player_ids:
247-
for item_link in self.item_links[player].value:
246+
for item_link in self.worlds[player].options.item_links.value:
248247
if item_link["name"] in item_links:
249248
if item_links[item_link["name"]]["game"] != self.game[player]:
250249
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
@@ -299,14 +298,6 @@ def set_item_links(self):
299298
group["non_local_items"] = item_link["non_local_items"]
300299
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
301300

302-
# intended for unittests
303-
def set_default_common_options(self):
304-
for option_key, option in Options.common_options.items():
305-
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
306-
for option_key, option in Options.per_game_common_options.items():
307-
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
308-
self.state = CollectionState(self)
309-
310301
def secure(self):
311302
self.random = ThreadBarrierProxy(secrets.SystemRandom())
312303
self.is_race = True
@@ -863,31 +854,31 @@ def add_locations(self, locations: Dict[str, Optional[int]],
863854
"""
864855
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
865856
location names to address.
866-
857+
867858
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
868859
:param location_type: Location class to be used to create the locations with"""
869860
if location_type is None:
870861
location_type = Location
871862
for location, address in locations.items():
872863
self.locations.append(location_type(self.player, location, address, self))
873-
864+
874865
def connect(self, connecting_region: Region, name: Optional[str] = None,
875866
rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
876867
"""
877868
Connects this Region to another Region, placing the provided rule on the connection.
878-
869+
879870
:param connecting_region: Region object to connect to path is `self -> exiting_region`
880871
:param name: name of the connection being created
881872
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
882873
exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}")
883874
if rule:
884875
exit_.access_rule = rule
885876
exit_.connect(connecting_region)
886-
877+
887878
def create_exit(self, name: str) -> Entrance:
888879
"""
889880
Creates and returns an Entrance object as an exit of this region.
890-
881+
891882
:param name: name of the Entrance being created
892883
"""
893884
exit_ = self.entrance_type(self.player, name, self)
@@ -1257,7 +1248,7 @@ def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, st
12571248

12581249
def to_file(self, filename: str) -> None:
12591250
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
1260-
res = getattr(self.multiworld, option_key)[player]
1251+
res = getattr(self.multiworld.worlds[player].options, option_key)
12611252
display_name = getattr(option_obj, "display_name", option_key)
12621253
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
12631254

@@ -1275,8 +1266,7 @@ def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
12751266
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
12761267
outfile.write('Game: %s\n' % self.multiworld.game[player])
12771268

1278-
options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
1279-
for f_option, option in options.items():
1269+
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
12801270
write_option(f_option, option)
12811271

12821272
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)

Fill.py

+7-5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from collections import Counter, deque
66

77
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
8+
from Options import Accessibility
9+
810
from worlds.AutoWorld import call_all
911
from worlds.generic.Rules import add_item_rule
1012

@@ -70,7 +72,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
7072
spot_to_fill: typing.Optional[Location] = None
7173

7274
# if minimal accessibility, only check whether location is reachable if game not beatable
73-
if world.accessibility[item_to_place.player] == 'minimal':
75+
if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
7476
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
7577
item_to_place.player) \
7678
if single_player_placement else not has_beaten_game
@@ -265,7 +267,7 @@ def fast_fill(world: MultiWorld,
265267

266268
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
267269
maximum_exploration_state = sweep_from_pool(state, pool)
268-
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
270+
minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"}
269271
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
270272
not location.can_reach(maximum_exploration_state)]
271273
for location in unreachable_locations:
@@ -288,7 +290,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat
288290
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
289291
if unreachable_locations:
290292
def forbid_important_item_rule(item: Item):
291-
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
293+
return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal')
292294

293295
for location in unreachable_locations:
294296
add_item_rule(location, forbid_important_item_rule)
@@ -531,9 +533,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
531533
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
532534
# which gives more locations available by this sphere.
533535
balanceable_players: typing.Dict[int, float] = {
534-
player: world.progression_balancing[player] / 100
536+
player: world.worlds[player].options.progression_balancing / 100
535537
for player in world.player_ids
536-
if world.progression_balancing[player] > 0
538+
if world.worlds[player].options.progression_balancing > 0
537539
}
538540
if not balanceable_players:
539541
logging.info('Skipping multiworld progression balancing.')

Generate.py

+7-11
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ def main(args=None, callback=ERmain):
157157
for yaml in weights_cache[path]:
158158
if category_name is None:
159159
for category in yaml:
160-
if category in AutoWorldRegister.world_types and key in Options.common_options:
160+
if category in AutoWorldRegister.world_types and \
161+
key in Options.CommonOptions.type_hints:
161162
yaml[category][key] = option
162163
elif category_name not in yaml:
163164
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
@@ -340,7 +341,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
340341
return get_choice(option_key, category_dict)
341342
if game in AutoWorldRegister.world_types:
342343
game_world = AutoWorldRegister.world_types[game]
343-
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
344+
options = game_world.options_dataclass.type_hints
344345
if option_key in options:
345346
if options[option_key].supports_weighting:
346347
return get_choice(option_key, category_dict)
@@ -445,8 +446,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
445446
f"which is not enabled.")
446447

447448
ret = argparse.Namespace()
448-
for option_key in Options.per_game_common_options:
449-
if option_key in weights and option_key not in Options.common_options:
449+
for option_key in Options.PerGameCommonOptions.type_hints:
450+
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
450451
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
451452

452453
ret.game = get_choice("game", weights)
@@ -466,16 +467,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
466467
game_weights = weights[ret.game]
467468

468469
ret.name = get_choice('name', weights)
469-
for option_key, option in Options.common_options.items():
470+
for option_key, option in Options.CommonOptions.type_hints.items():
470471
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
471472

472-
for option_key, option in world_type.option_definitions.items():
473+
for option_key, option in world_type.options_dataclass.type_hints.items():
473474
handle_option(ret, game_weights, option_key, option, plando_options)
474-
for option_key, option in Options.per_game_common_options.items():
475-
# skip setting this option if already set from common_options, defaulting to root option
476-
if option_key not in world_type.option_definitions and \
477-
(option_key not in Options.common_options or option_key in game_weights):
478-
handle_option(ret, game_weights, option_key, option, plando_options)
479475
if PlandoOptions.items in plando_options:
480476
ret.plando_items = game_weights.get("plando_items", [])
481477
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":

Main.py

+11-11
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
108108
logger.info('')
109109

110110
for player in world.player_ids:
111-
for item_name, count in world.start_inventory[player].value.items():
111+
for item_name, count in world.worlds[player].options.start_inventory.value.items():
112112
for _ in range(count):
113113
world.push_precollected(world.create_item(item_name, player))
114114

@@ -130,15 +130,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
130130

131131
for player in world.player_ids:
132132
# items can't be both local and non-local, prefer local
133-
world.non_local_items[player].value -= world.local_items[player].value
134-
world.non_local_items[player].value -= set(world.local_early_items[player])
133+
world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value
134+
world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player])
135135

136136
AutoWorld.call_all(world, "set_rules")
137137

138138
for player in world.player_ids:
139-
exclusion_rules(world, player, world.exclude_locations[player].value)
140-
world.priority_locations[player].value -= world.exclude_locations[player].value
141-
for location_name in world.priority_locations[player].value:
139+
exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value)
140+
world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value
141+
for location_name in world.worlds[player].options.priority_locations.value:
142142
try:
143143
location = world.get_location(location_name, player)
144144
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
@@ -151,8 +151,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
151151
if world.players > 1:
152152
locality_rules(world)
153153
else:
154-
world.non_local_items[1].value = set()
155-
world.local_items[1].value = set()
154+
world.worlds[1].options.non_local_items.value = set()
155+
world.worlds[1].options.local_items.value = set()
156156

157157
AutoWorld.call_all(world, "generate_basic")
158158

@@ -360,11 +360,11 @@ def precollect_hint(location):
360360
f" {location}"
361361
locations_data[location.player][location.address] = \
362362
location.item.code, location.item.player, location.item.flags
363-
if location.name in world.start_location_hints[location.player]:
363+
if location.name in world.worlds[location.player].options.start_location_hints:
364364
precollect_hint(location)
365-
elif location.item.name in world.start_hints[location.item.player]:
365+
elif location.item.name in world.worlds[location.item.player].options.start_hints:
366366
precollect_hint(location)
367-
elif any([location.item.name in world.start_hints[player]
367+
elif any([location.item.name in world.worlds[player].options.start_hints
368368
for player in world.groups.get(location.item.player, {}).get("players", [])]):
369369
precollect_hint(location)
370370

Options.py

+69-19
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import abc
44
import logging
5+
from copy import deepcopy
6+
from dataclasses import dataclass
7+
import functools
58
import math
69
import numbers
710
import random
@@ -211,6 +214,12 @@ def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
211214
else:
212215
return self.value > other
213216

217+
def __ge__(self, other: typing.Union[int, NumericOption]) -> bool:
218+
if isinstance(other, NumericOption):
219+
return self.value >= other.value
220+
else:
221+
return self.value >= other
222+
214223
def __bool__(self) -> bool:
215224
return bool(self.value)
216225

@@ -896,10 +905,55 @@ class ProgressionBalancing(SpecialRange):
896905
}
897906

898907

899-
common_options = {
900-
"progression_balancing": ProgressionBalancing,
901-
"accessibility": Accessibility
902-
}
908+
class OptionsMetaProperty(type):
909+
def __new__(mcs,
910+
name: str,
911+
bases: typing.Tuple[type, ...],
912+
attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
913+
for attr_type in attrs.values():
914+
assert not isinstance(attr_type, AssembleOptions),\
915+
f"Options for {name} should be type hinted on the class, not assigned"
916+
return super().__new__(mcs, name, bases, attrs)
917+
918+
@property
919+
@functools.lru_cache(maxsize=None)
920+
def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]:
921+
"""Returns type hints of the class as a dictionary."""
922+
return typing.get_type_hints(cls)
923+
924+
925+
@dataclass
926+
class CommonOptions(metaclass=OptionsMetaProperty):
927+
progression_balancing: ProgressionBalancing
928+
accessibility: Accessibility
929+
930+
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
931+
"""
932+
Returns a dictionary of [str, Option.value]
933+
934+
:param option_names: names of the options to return
935+
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
936+
"""
937+
option_results = {}
938+
for option_name in option_names:
939+
if option_name in type(self).type_hints:
940+
if casing == "snake":
941+
display_name = option_name
942+
elif casing == "camel":
943+
split_name = [name.title() for name in option_name.split("_")]
944+
split_name[0] = split_name[0].lower()
945+
display_name = "".join(split_name)
946+
elif casing == "pascal":
947+
display_name = "".join([name.title() for name in option_name.split("_")])
948+
elif casing == "kebab":
949+
display_name = option_name.replace("_", "-")
950+
else:
951+
raise ValueError(f"{casing} is invalid casing for as_dict. "
952+
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
953+
option_results[display_name] = getattr(self, option_name).value
954+
else:
955+
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
956+
return option_results
903957

904958

905959
class LocalItems(ItemSet):
@@ -1020,17 +1074,16 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P
10201074
link.setdefault("link_replacement", None)
10211075

10221076

1023-
per_game_common_options = {
1024-
**common_options, # can be overwritten per-game
1025-
"local_items": LocalItems,
1026-
"non_local_items": NonLocalItems,
1027-
"start_inventory": StartInventory,
1028-
"start_hints": StartHints,
1029-
"start_location_hints": StartLocationHints,
1030-
"exclude_locations": ExcludeLocations,
1031-
"priority_locations": PriorityLocations,
1032-
"item_links": ItemLinks
1033-
}
1077+
@dataclass
1078+
class PerGameCommonOptions(CommonOptions):
1079+
local_items: LocalItems
1080+
non_local_items: NonLocalItems
1081+
start_inventory: StartInventory
1082+
start_hints: StartHints
1083+
start_location_hints: StartLocationHints
1084+
exclude_locations: ExcludeLocations
1085+
priority_locations: PriorityLocations
1086+
item_links: ItemLinks
10341087

10351088

10361089
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
@@ -1071,10 +1124,7 @@ def dictify_range(option: typing.Union[Range, SpecialRange]):
10711124

10721125
for game_name, world in AutoWorldRegister.world_types.items():
10731126
if not world.hidden or generate_hidden:
1074-
all_options: typing.Dict[str, AssembleOptions] = {
1075-
**per_game_common_options,
1076-
**world.option_definitions
1077-
}
1127+
all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints
10781128

10791129
with open(local_path("data", "options.yaml")) as f:
10801130
file_data = f.read()

0 commit comments

Comments
 (0)