""" Contains the Custom Mission Order option. Also validates the option value, so generation can assume the data matches the specification. """ from __future__ import annotations import random from Options import OptionDict, Visibility from schema import Schema, Optional, And, Or import typing from typing import Any, Union, Dict, Set, List import copy from ..mission_tables import lookup_name_to_mission from ..mission_groups import mission_groups from ..item.item_tables import item_table from ..item.item_groups import item_name_groups from . import layout_types from .layout_types import LayoutType, Column, Grid, Hopscotch, Gauntlet, Blitz, Canvas from .mission_pools import Difficulty from .presets_static import ( static_preset, preset_mini_wol_with_prophecy, preset_mini_wol, preset_mini_hots, preset_mini_prophecy, preset_mini_lotv_prologue, preset_mini_lotv, preset_mini_lotv_epilogue, preset_mini_nco, preset_wol_with_prophecy, preset_wol, preset_prophecy, preset_hots, preset_lotv_prologue, preset_lotv_epilogue, preset_lotv, preset_nco ) from .presets_scripted import make_golden_path GENERIC_KEY_NAME = "Key".casefold() GENERIC_PROGRESSIVE_KEY_NAME = "Progressive Key".casefold() STR_OPTION_VALUES: Dict[str, Dict[str, Any]] = { "type": { "column": Column.__name__, "grid": Grid.__name__, "hopscotch": Hopscotch.__name__, "gauntlet": Gauntlet.__name__, "blitz": Blitz.__name__, "canvas": Canvas.__name__, }, "difficulty": { "relative": Difficulty.RELATIVE.value, "starter": Difficulty.STARTER.value, "easy": Difficulty.EASY.value, "medium": Difficulty.MEDIUM.value, "hard": Difficulty.HARD.value, "very hard": Difficulty.VERY_HARD.value }, "preset": { "none": lambda _: {}, "wol + prophecy": static_preset(preset_wol_with_prophecy), "wol": static_preset(preset_wol), "prophecy": static_preset(preset_prophecy), "hots": static_preset(preset_hots), "prologue": static_preset(preset_lotv_prologue), "lotv prologue": static_preset(preset_lotv_prologue), "lotv": static_preset(preset_lotv), "epilogue": static_preset(preset_lotv_epilogue), "lotv epilogue": static_preset(preset_lotv_epilogue), "nco": static_preset(preset_nco), "mini wol + prophecy": static_preset(preset_mini_wol_with_prophecy), "mini wol": static_preset(preset_mini_wol), "mini prophecy": static_preset(preset_mini_prophecy), "mini hots": static_preset(preset_mini_hots), "mini prologue": static_preset(preset_mini_lotv_prologue), "mini lotv prologue": static_preset(preset_mini_lotv_prologue), "mini lotv": static_preset(preset_mini_lotv), "mini epilogue": static_preset(preset_mini_lotv_epilogue), "mini lotv epilogue": static_preset(preset_mini_lotv_epilogue), "mini nco": static_preset(preset_mini_nco), "golden path": make_golden_path }, } STR_OPTION_VALUES["min_difficulty"] = STR_OPTION_VALUES["difficulty"] STR_OPTION_VALUES["max_difficulty"] = STR_OPTION_VALUES["difficulty"] GLOBAL_ENTRY = "global" StrOption = lambda cat: And(str, lambda val: val.lower() in STR_OPTION_VALUES[cat]) IntNegOne = And(int, lambda val: val >= -1) IntZero = And(int, lambda val: val >= 0) IntOne = And(int, lambda val: val >= 1) IntPercent = And(int, lambda val: 0 <= val <= 100) IntZeroToTen = And(int, lambda val: 0 <= val <= 10) SubRuleEntryRule = { "rules": [{str: object}], # recursive schema checking is too hard "amount": IntNegOne, } MissionCountEntryRule = { "scope": [str], "amount": IntNegOne, } BeatMissionsEntryRule = { "scope": [str], } ItemEntryRule = { "items": {str: int} } EntryRule = Or(SubRuleEntryRule, MissionCountEntryRule, BeatMissionsEntryRule, ItemEntryRule) SchemaDifficulty = Or(*[value.value for value in Difficulty]) class CustomMissionOrder(OptionDict): """ Used to generate a custom mission order. Please see documentation to understand usage. Will do nothing unless `mission_order` is set to `custom`. """ display_name = "Custom Mission Order" visibility = Visibility.template value: Dict[str, Dict[str, Any]] default = { "Default Campaign": { "display_name": "null", "unique_name": False, "entry_rules": [], "unique_progression_track": 0, "goal": True, "min_difficulty": "relative", "max_difficulty": "relative", GLOBAL_ENTRY: { "display_name": "null", "unique_name": False, "entry_rules": [], "unique_progression_track": 0, "goal": False, "exit": False, "mission_pool": ["all missions"], "min_difficulty": "relative", "max_difficulty": "relative", "missions": [], }, "Default Layout": { "type": "grid", "size": 9, }, }, } schema = Schema({ # Campaigns str: { "display_name": [str], "unique_name": bool, "entry_rules": [EntryRule], "unique_progression_track": int, "goal": bool, "min_difficulty": SchemaDifficulty, "max_difficulty": SchemaDifficulty, "single_layout_campaign": bool, # Layouts str: { "display_name": [str], "unique_name": bool, # Type options "type": lambda val: issubclass(getattr(layout_types, val), LayoutType), "size": IntOne, # Link options "exit": bool, "goal": bool, "entry_rules": [EntryRule], "unique_progression_track": int, # Mission pool options "mission_pool": {int}, "min_difficulty": SchemaDifficulty, "max_difficulty": SchemaDifficulty, # Allow arbitrary options for layout types Optional(str): Or(int, str, bool, [Or(int, str, bool)]), # Mission slots "missions": [{ "index": [Or(int, str)], Optional("entrance"): bool, Optional("exit"): bool, Optional("goal"): bool, Optional("empty"): bool, Optional("next"): [Or(int, str)], Optional("entry_rules"): [EntryRule], Optional("mission_pool"): {int}, Optional("difficulty"): SchemaDifficulty, Optional("victory_cache"): IntZeroToTen, }], }, } }) def __init__(self, yaml_value: Dict[str, Dict[str, Any]]) -> None: # This function constructs self.value by parts, # so the parent constructor isn't called self.value: Dict[str, Dict[str, Any]] = {} if yaml_value == self.default: # If this option is default, it shouldn't mess with its own values yaml_value = copy.deepcopy(self.default) for campaign in yaml_value: self.value[campaign] = {} # Check if this campaign has a layout type, making it a campaign-level layout single_layout_campaign = "type" in yaml_value[campaign] if single_layout_campaign: # Single-layout campaigns are not allowed to declare more layouts single_layout = {key: val for (key, val) in yaml_value[campaign].items() if type(val) != dict} yaml_value[campaign] = {campaign: single_layout} # Campaign should inherit certain values from the layout if "goal" not in single_layout or not single_layout["goal"]: yaml_value[campaign]["goal"] = False if "unique_progression_track" in single_layout: yaml_value[campaign]["unique_progression_track"] = single_layout["unique_progression_track"] # Hide campaign name for single-layout campaigns yaml_value[campaign]["display_name"] = "" yaml_value[campaign]["single_layout_campaign"] = single_layout_campaign # Check if this campaign has a global layout global_dict = {} for name in yaml_value[campaign]: if name.lower() == GLOBAL_ENTRY: global_dict = yaml_value[campaign].pop(name) break # Strip layouts and unknown options from the campaign # The latter are assumed to be preset options preset_key: str = yaml_value[campaign].pop("preset", "none") layout_keys = [key for (key, val) in yaml_value[campaign].items() if type(val) == dict] layouts = {key: yaml_value[campaign].pop(key) for key in layout_keys} preset_option_keys = [key for key in yaml_value[campaign] if key not in self.default["Default Campaign"]] preset_option_keys.remove("single_layout_campaign") preset_options = {key: yaml_value[campaign].pop(key) for key in preset_option_keys} # Resolve preset preset: Dict[str, Any] = _resolve_string_option_single("preset", preset_key)(preset_options) # Preset global is resolved internally to avoid conflict with user global preset_global_dict = {} for name in preset: if name.lower() == GLOBAL_ENTRY: preset_global_dict = preset.pop(name) break preset_layout_keys = [key for (key, val) in preset.items() if type(val) == dict] preset_layouts = {key: preset.pop(key) for key in preset_layout_keys} ordered_layouts = {key: copy.deepcopy(preset_global_dict) for key in preset_layout_keys} for key in preset_layout_keys: ordered_layouts[key].update(preset_layouts[key]) # Final layouts are preset layouts (updated by same-name user layouts) followed by custom user layouts for key in layouts: if key in ordered_layouts: # Mission slots for presets should go before user mission slots if "missions" in layouts[key] and "missions" in ordered_layouts[key]: layouts[key]["missions"] = ordered_layouts[key]["missions"] + layouts[key]["missions"] ordered_layouts[key].update(layouts[key]) else: ordered_layouts[key] = layouts[key] # Campaign values = default options (except for default layouts) + preset options (except for layouts) + campaign options self.value[campaign] = {key: value for (key, value) in self.default["Default Campaign"].items() if type(value) != dict} self.value[campaign].update(preset) self.value[campaign].update(yaml_value[campaign]) _resolve_special_options(self.value[campaign]) for layout in ordered_layouts: # Layout values = default options + campaign's global options + layout options self.value[campaign][layout] = copy.deepcopy(self.default["Default Campaign"][GLOBAL_ENTRY]) self.value[campaign][layout].update(global_dict) self.value[campaign][layout].update(ordered_layouts[layout]) _resolve_special_options(self.value[campaign][layout]) for mission_slot_index in range(len(self.value[campaign][layout]["missions"])): # Defaults for mission slots are handled by the mission slot struct _resolve_special_options(self.value[campaign][layout]["missions"][mission_slot_index]) # Overloaded to remove pre-init schema validation # Schema is still validated after __init__ @classmethod def from_any(cls, data: Dict[str, Any]) -> CustomMissionOrder: if type(data) == dict: return cls(data) else: raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}") def _resolve_special_options(data: Dict[str, Any]): # Handle range values & string-to-value conversions for option in data: option_value = data[option] new_value = _resolve_special_option(option, option_value) data[option] = new_value # Special case for canvas layouts determining their own size if "type" in data and data["type"] == Canvas.__name__: canvas: List[str] = data["canvas"] longest_line = max(len(line) for line in canvas) data["size"] = len(canvas) * longest_line data["width"] = longest_line def _resolve_special_option(option: str, option_value: Any) -> Any: # Option values can be string representations of values if option in STR_OPTION_VALUES: return _resolve_string_option(option, option_value) if option == "mission_pool": return _resolve_mission_pool(option_value) if option == "entry_rules": rules = [_resolve_entry_rule(subrule) for subrule in option_value] return rules if option == "display_name": # Make sure all the values are strings if type(option_value) == list: names = [str(value) for value in option_value] return names elif option_value == "null": # "null" means no custom display name return [] else: return [str(option_value)] if option in ["index", "next"]: # All index values could be ranges if type(option_value) == list: # Flatten any nested lists indices = [idx for val in [idx if type(idx) == list else [idx] for idx in option_value] for idx in val] indices = [_resolve_potential_range(index) for index in indices] indices = [idx if type(idx) == int else str(idx) for idx in indices] return indices else: idx = _resolve_potential_range(option_value) return [idx if type(idx) == int else str(idx)] # Option values can be ranges return _resolve_potential_range(option_value) def _resolve_string_option_single(option: str, option_value: str) -> Any: formatted_value = option_value.lower().replace("_", " ") if formatted_value not in STR_OPTION_VALUES[option]: raise ValueError( f"Option \"{option}\" received unknown value \"{option_value}\".\n" f"Allowed values are: {list(STR_OPTION_VALUES[option].keys())}" ) return STR_OPTION_VALUES[option][formatted_value] def _resolve_string_option(option: str, option_value: Union[List[str], str]) -> Any: if type(option_value) == list: return [_resolve_string_option_single(option, val) for val in option_value] else: return _resolve_string_option_single(option, option_value) def _resolve_entry_rule(option_value: Dict[str, Any]) -> Dict[str, Any]: resolved: Dict[str, Any] = {} mutually_exclusive: List[str] = [] if "amount" in option_value: resolved["amount"] = _resolve_potential_range(option_value["amount"]) if "scope" in option_value: mutually_exclusive.append("scope") # A scope may be a list or a single address if type(option_value["scope"]) == list: resolved["scope"] = [str(subscope) for subscope in option_value["scope"]] else: resolved["scope"] = [str(option_value["scope"])] if "rules" in option_value: mutually_exclusive.append("rules") resolved["rules"] = [_resolve_entry_rule(subrule) for subrule in option_value["rules"]] # Make sure sub-rule rules have a specified amount if "amount" not in option_value: resolved["amount"] = -1 if "items" in option_value: mutually_exclusive.append("items") option_items: Dict[str, Any] = option_value["items"] resolved_items = {item: int(_resolve_potential_range(str(amount))) for (item, amount) in option_items.items()} resolved_items = _resolve_item_names(resolved_items) resolved["items"] = {} for item in resolved_items: if item not in item_table: if item.casefold() == GENERIC_KEY_NAME or item.casefold().startswith(GENERIC_PROGRESSIVE_KEY_NAME): resolved["items"][item] = max(0, resolved_items[item]) continue raise ValueError(f"Item rule contains \"{item}\", which is not a valid item name.") amount = max(0, resolved_items[item]) quantity = item_table[item].quantity if amount == 0: final_amount = quantity elif quantity == 0: final_amount = amount else: final_amount = amount resolved["items"][item] = final_amount if len(mutually_exclusive) > 1: raise ValueError( "Entry rule contains too many identifiers.\n" f"Rule: {option_value}\n" f"Remove all but one of these entries: {mutually_exclusive}" ) return resolved def _resolve_potential_range(option_value: Union[Any, str]) -> Union[Any, int]: # An option value may be a range if type(option_value) == str and option_value.startswith("random-range-"): resolved = _custom_range(option_value) return resolved else: # As this is a catch-all function, # assume non-range option values are handled elsewhere # or intended to fall through return option_value def _resolve_mission_pool(option_value: Union[str, List[str]]) -> Set[int]: if type(option_value) == str: pool = _get_target_missions(option_value) else: pool: Set[int] = set() for line in option_value: if line.startswith("~"): if len(pool) == 0: raise ValueError(f"Mission Pool term {line} tried to remove missions from an empty pool.") term = line[1:].strip() missions = _get_target_missions(term) pool.difference_update(missions) elif line.startswith("^"): if len(pool) == 0: raise ValueError(f"Mission Pool term {line} tried to remove missions from an empty pool.") term = line[1:].strip() missions = _get_target_missions(term) pool.intersection_update(missions) else: if line.startswith("+"): term = line[1:].strip() else: term = line.strip() missions = _get_target_missions(term) pool.update(missions) if len(pool) == 0: raise ValueError(f"Mission pool evaluated to zero missions: {option_value}") return pool def _get_target_missions(term: str) -> Set[int]: if term in lookup_name_to_mission: return {lookup_name_to_mission[term].id} else: groups = [mission_groups[group] for group in mission_groups if group.casefold() == term.casefold()] if len(groups) > 0: return {lookup_name_to_mission[mission].id for mission in groups[0]} else: raise ValueError(f"Mission pool term \"{term}\" did not resolve to any specific mission or mission group.") # Class-agnostic version of AP Options.Range.custom_range def _custom_range(text: str) -> int: textsplit = text.split("-") try: random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])] except ValueError: raise ValueError(f"Invalid random range {text} for option {CustomMissionOrder.__name__}") random_range.sort() if text.startswith("random-range-low"): return _triangular(random_range[0], random_range[1], random_range[0]) elif text.startswith("random-range-middle"): return _triangular(random_range[0], random_range[1]) elif text.startswith("random-range-high"): return _triangular(random_range[0], random_range[1], random_range[1]) else: return random.randint(random_range[0], random_range[1]) def _triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int: return int(round(random.triangular(lower, end, tri), 0)) # Version of options.Sc2ItemDict.verify without World def _resolve_item_names(value: Dict[str, int]) -> Dict[str, int]: new_value: dict[str, int] = {} case_insensitive_group_mapping = { group_name.casefold(): group_value for group_name, group_value in item_name_groups.items() } case_insensitive_group_mapping.update({item.casefold(): {item} for item in item_table}) for group_name in value: item_names = case_insensitive_group_mapping.get(group_name.casefold(), {group_name}) for item_name in item_names: new_value[item_name] = new_value.get(item_name, 0) + value[group_name] return new_value