Files
Grinch-AP/worlds/sc2/mission_order/options.py
Ziktofel 5f1835c546 SC2: Content update (#5312)
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
2025-09-02 17:40:58 +02:00

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