Feature highlights: - Adds many content to the SC2 game - Allows custom mission order - Adds race-swapped missions for build missions (except Epilogue and NCO) - Allows War Council Nerfs (Protoss units can get pre - War Council State, alternative units get another custom nerf to match the power level of base units) - Revamps Predator's upgrade tree (never was considered strategically important) - Adds some units and upgrades - Locked and excluded items can specify quantity - Key mode (if opt-in, missions require keys to be unlocked on top of their regular regular requirements - Victory caches - Victory locations can grant multiple items to the multiworld instead of one - The generator is more resilient for generator failures as it validates logic for item excludes - Fixes the following issues: - https://github.com/ArchipelagoMW/Archipelago/issues/3531 - https://github.com/ArchipelagoMW/Archipelago/issues/3548
472 lines
21 KiB
Python
472 lines
21 KiB
Python
"""
|
|
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
|
|
|