Files
Grinch-AP/worlds/sc2/mission_order/nodes.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

607 lines
25 KiB
Python

"""
Contains the data structures that make up a mission order.
Data in these structures is validated in .options.py and manipulated by .generation.py.
"""
from __future__ import annotations
from typing import Dict, Set, Callable, List, Any, Type, Optional, Union, TYPE_CHECKING
from weakref import ref, ReferenceType
from dataclasses import asdict
from abc import ABC, abstractmethod
import logging
from BaseClasses import Region, CollectionState
from ..mission_tables import SC2Mission
from ..item import item_names
from .layout_types import LayoutType
from .entry_rules import SubRuleEntryRule, ItemEntryRule
from .mission_pools import Difficulty
from .slot_data import CampaignSlotData, LayoutSlotData, MissionSlotData
if TYPE_CHECKING:
from .. import SC2World
class MissionOrderNode(ABC):
parent: Optional[ReferenceType[MissionOrderNode]]
important_beat_event: bool
def get_parent(self, address_so_far: str, full_address: str) -> MissionOrderNode:
if self.parent is None:
raise ValueError(
f"Address \"{address_so_far}\" (from \"{full_address}\") could not find a parent object. "
"This should mean the address contains \"..\" too often."
)
return self.parent()
@abstractmethod
def search(self, term: str) -> Union[List[MissionOrderNode], None]:
raise NotImplementedError
@abstractmethod
def child_type_name(self) -> str:
raise NotImplementedError
@abstractmethod
def get_missions(self) -> List[SC2MOGenMission]:
raise NotImplementedError
@abstractmethod
def get_exits(self) -> List[SC2MOGenMission]:
raise NotImplementedError
@abstractmethod
def get_visual_requirement(self, start_node: MissionOrderNode) -> Union[str, SC2MOGenMission]:
raise NotImplementedError
@abstractmethod
def get_key_name(self) -> str:
raise NotImplementedError
@abstractmethod
def get_min_depth(self) -> int:
raise NotImplementedError
@abstractmethod
def get_address_to_node(self) -> str:
raise NotImplementedError
class SC2MOGenMissionOrder(MissionOrderNode):
"""
The top-level data structure for mission orders.
"""
campaigns: List[SC2MOGenCampaign]
sorted_missions: Dict[Difficulty, List[SC2MOGenMission]]
"""All mission slots in the mission order sorted by their difficulty, but not their depth."""
fixed_missions: List[SC2MOGenMission]
"""All mission slots that have a plando'd mission."""
items_to_lock: Dict[str, int]
keys_to_resolve: Dict[MissionOrderNode, List[ItemEntryRule]]
goal_missions: List[SC2MOGenMission]
max_depth: int
def __init__(self, world: 'SC2World', data: Dict[str, Any]):
self.campaigns = []
self.sorted_missions = {diff: [] for diff in Difficulty if diff != Difficulty.RELATIVE}
self.fixed_missions = []
self.items_to_lock = {}
self.keys_to_resolve = {}
self.goal_missions = []
self.parent = None
for (campaign_name, campaign_data) in data.items():
campaign = SC2MOGenCampaign(world, ref(self), campaign_name, campaign_data)
self.campaigns.append(campaign)
# Check that the mission order actually has a goal
for campaign in self.campaigns:
if campaign.option_goal:
self.goal_missions.extend(mission for mission in campaign.exits)
for layout in campaign.layouts:
if layout.option_goal:
self.goal_missions.extend(layout.exits)
for mission in layout.missions:
if mission.option_goal and not mission.option_empty:
self.goal_missions.append(mission)
# Remove duplicates
for goal in self.goal_missions:
while self.goal_missions.count(goal) > 1:
self.goal_missions.remove(goal)
# If not, set the last defined campaign as goal
if len(self.goal_missions) == 0:
self.campaigns[-1].option_goal = True
self.goal_missions.extend(mission for mission in self.campaigns[-1].exits)
# Apply victory cache option wherever the value has not yet been defined; must happen after goal missions are decided
for mission in self.get_missions():
if mission.option_victory_cache != -1:
# Already set
continue
if mission in self.goal_missions:
mission.option_victory_cache = 0
else:
mission.option_victory_cache = world.options.victory_cache.value
# Resolve names
used_names: Set[str] = set()
for campaign in self.campaigns:
names = [campaign.option_name] if len(campaign.option_display_name) == 0 else campaign.option_display_name
if campaign.option_unique_name:
names = [name for name in names if name not in used_names]
campaign.display_name = world.random.choice(names)
used_names.add(campaign.display_name)
for layout in campaign.layouts:
names = [layout.option_name] if len(layout.option_display_name) == 0 else layout.option_display_name
if layout.option_unique_name:
names = [name for name in names if name not in used_names]
layout.display_name = world.random.choice(names)
used_names.add(layout.display_name)
def get_slot_data(self) -> List[Dict[str, Any]]:
# [(campaign data, [(layout data, [[(mission data)]] )] )]
return [asdict(campaign.get_slot_data()) for campaign in self.campaigns]
def search(self, term: str) -> Union[List[MissionOrderNode], None]:
return [
campaign.layouts[0] if campaign.option_single_layout_campaign else campaign
for campaign in self.campaigns
if campaign.option_name.casefold() == term.casefold()
]
def child_type_name(self) -> str:
return "Campaign"
def get_missions(self) -> List[SC2MOGenMission]:
return [mission for campaign in self.campaigns for layout in campaign.layouts for mission in layout.missions]
def get_exits(self) -> List[SC2MOGenMission]:
return []
def get_visual_requirement(self, _start_node: MissionOrderNode) -> Union[str, SC2MOGenMission]:
return "All Missions"
def get_key_name(self) -> str:
return super().get_key_name() # type: ignore
def get_min_depth(self) -> int:
return super().get_min_depth() # type: ignore
def get_address_to_node(self):
return self.campaigns[0].get_address_to_node() + "/.."
class SC2MOGenCampaign(MissionOrderNode):
option_name: str # name of this campaign
option_display_name: List[str]
option_unique_name: bool
option_entry_rules: List[Dict[str, Any]]
option_unique_progression_track: int # progressive keys under this campaign and on this track will be changed to a unique track
option_goal: bool # whether this campaign is required to beat the game
# minimum difficulty of this campaign
# 'relative': based on the median distance of the first mission
option_min_difficulty: Difficulty
# maximum difficulty of this campaign
# 'relative': based on the median distance of the last mission
option_max_difficulty: Difficulty
option_single_layout_campaign: bool
# layouts of this campaign in correct order
layouts: List[SC2MOGenLayout]
exits: List[SC2MOGenMission] # missions required to beat this campaign (missions marked "exit" in layouts marked "exit")
entry_rule: SubRuleEntryRule
display_name: str
min_depth: int
max_depth: int
def __init__(self, world: 'SC2World', parent: ReferenceType[SC2MOGenMissionOrder], name: str, data: Dict[str, Any]):
self.parent = parent
self.important_beat_event = False
self.option_name = name
self.option_display_name = data["display_name"]
self.option_unique_name = data["unique_name"]
self.option_goal = data["goal"]
self.option_entry_rules = data["entry_rules"]
self.option_unique_progression_track = data["unique_progression_track"]
self.option_min_difficulty = Difficulty(data["min_difficulty"])
self.option_max_difficulty = Difficulty(data["max_difficulty"])
self.option_single_layout_campaign = data["single_layout_campaign"]
self.layouts = []
self.exits = []
for (layout_name, layout_data) in data.items():
if type(layout_data) == dict:
layout = SC2MOGenLayout(world, ref(self), layout_name, layout_data)
self.layouts.append(layout)
# Collect required missions (marked layouts' exits)
if layout.option_exit:
self.exits.extend(layout.exits)
# If no exits are set, use the last defined layout
if len(self.exits) == 0:
self.layouts[-1].option_exit = True
self.exits.extend(self.layouts[-1].exits)
def is_beaten(self, beaten_missions: Set[SC2MOGenMission]) -> bool:
return beaten_missions.issuperset(self.exits)
def is_always_unlocked(self, in_region_creation = False) -> bool:
return self.entry_rule.is_always_fulfilled(in_region_creation)
def is_unlocked(self, beaten_missions: Set[SC2MOGenMission], in_region_creation = False) -> bool:
return self.entry_rule.is_fulfilled(beaten_missions, in_region_creation)
def search(self, term: str) -> Union[List[MissionOrderNode], None]:
return [
layout
for layout in self.layouts
if layout.option_name.casefold() == term.casefold()
]
def child_type_name(self) -> str:
return "Layout"
def get_missions(self) -> List[SC2MOGenMission]:
return [mission for layout in self.layouts for mission in layout.missions]
def get_exits(self) -> List[SC2MOGenMission]:
return self.exits
def get_visual_requirement(self, start_node: MissionOrderNode) -> Union[str, SC2MOGenMission]:
visual_name = self.get_visual_name()
# Needs special handling for double-parent, which is valid for missions but errors for campaigns
first_parent = start_node.get_parent("", "")
if (
first_parent is self or (
first_parent.parent is not None and first_parent.get_parent("", "") is self
)
) and visual_name == "":
return "this campaign"
return visual_name
def get_visual_name(self) -> str:
return self.display_name
def get_key_name(self) -> str:
return item_names._TEMPLATE_NAMED_CAMPAIGN_KEY.format(self.get_visual_name())
def get_min_depth(self) -> int:
return self.min_depth
def get_address_to_node(self) -> str:
return f"{self.option_name}"
def get_slot_data(self) -> CampaignSlotData:
if self.important_beat_event:
exits = [slot.mission.id for slot in self.exits]
else:
exits = []
return CampaignSlotData(
self.get_visual_name(),
asdict(self.entry_rule.to_slot_data()),
exits,
[asdict(layout.get_slot_data()) for layout in self.layouts]
)
class SC2MOGenLayout(MissionOrderNode):
option_name: str # name of this layout
option_display_name: List[str] # visual name of this layout
option_unique_name: bool
option_type: Type[LayoutType] # type of this layout
option_size: int # amount of missions in this layout
option_goal: bool # whether this layout is required to beat the game
option_exit: bool # whether this layout is required to beat its parent campaign
option_mission_pool: List[int] # IDs of valid missions for this layout
option_missions: List[Dict[str, Any]]
option_entry_rules: List[Dict[str, Any]]
option_unique_progression_track: int # progressive keys under this layout and on this track will be changed to a unique track
# minimum difficulty of this layout
# 'relative': based on the median distance of the first mission
option_min_difficulty: Difficulty
# maximum difficulty of this layout
# 'relative': based on the median distance of the last mission
option_max_difficulty: Difficulty
missions: List[SC2MOGenMission]
layout_type: LayoutType
entrances: List[SC2MOGenMission]
exits: List[SC2MOGenMission]
entry_rule: SubRuleEntryRule
display_name: str
min_depth: int
max_depth: int
def __init__(self, world: 'SC2World', parent: ReferenceType[SC2MOGenCampaign], name: str, data: Dict):
self.parent: ReferenceType[SC2MOGenCampaign] = parent
self.important_beat_event = False
self.option_name = name
self.option_display_name = data.pop("display_name")
self.option_unique_name = data.pop("unique_name")
self.option_type = data.pop("type")
self.option_size = data.pop("size")
self.option_goal = data.pop("goal")
self.option_exit = data.pop("exit")
self.option_mission_pool = data.pop("mission_pool")
self.option_missions = data.pop("missions")
self.option_entry_rules = data.pop("entry_rules")
self.option_unique_progression_track = data.pop("unique_progression_track")
self.option_min_difficulty = Difficulty(data.pop("min_difficulty"))
self.option_max_difficulty = Difficulty(data.pop("max_difficulty"))
self.missions = []
self.entrances = []
self.exits = []
# Check for positive size now instead of during YAML validation to actively error with default size
if self.option_size == 0:
raise ValueError(f"Layout \"{self.option_name}\" has a size of 0.")
# Build base layout
from . import layout_types
self.layout_type: LayoutType = getattr(layout_types, self.option_type)(self.option_size)
unused = self.layout_type.set_options(data)
if len(unused) > 0:
logging.warning(f"SC2 ({world.player_name}): Layout \"{self.option_name}\" has unknown options: {list(unused.keys())}")
mission_factory = lambda: SC2MOGenMission(ref(self), set(self.option_mission_pool))
self.missions = self.layout_type.make_slots(mission_factory)
# Update missions with user data
for mission_data in self.option_missions:
indices: Set[int] = set()
index_terms: List[Union[int, str]] = mission_data["index"]
for term in index_terms:
result = self.resolve_index_term(term)
indices.update(result)
for idx in indices:
self.missions[idx].update_with_data(mission_data)
# Let layout respond to user changes
self.layout_type.final_setup(self.missions)
for mission in self.missions:
if mission.option_entrance:
self.entrances.append(mission)
if mission.option_exit:
self.exits.append(mission)
if mission.option_next is not None:
mission.next = [self.missions[idx] for term in mission.option_next for idx in sorted(self.resolve_index_term(term))]
# Set up missions' prev data
for mission in self.missions:
for next_mission in mission.next:
next_mission.prev.append(mission)
# Remove empty missions from access data
for mission in self.missions:
if mission.option_empty:
for next_mission in mission.next:
next_mission.prev.remove(mission)
mission.next.clear()
for prev_mission in mission.prev:
prev_mission.next.remove(mission)
mission.prev.clear()
# Clean up data and options
all_empty = True
for mission in self.missions:
if mission.option_empty:
# Empty missions cannot be entrances, exits, or required
# This is done now instead of earlier to make "set all default entrances to empty" not fail
if mission in self.entrances:
self.entrances.remove(mission)
mission.option_entrance = False
if mission in self.exits:
self.exits.remove(mission)
mission.option_exit = False
mission.option_goal = False
# Empty missions are also not allowed to cause secondary effects via entry rules (eg. create key items)
mission.option_entry_rules = []
else:
all_empty = False
# Establish the following invariant:
# A non-empty mission has no prev missions <=> A non-empty mission is an entrance
# This is mandatory to guarantee the entire layout is accessible via consecutive .nexts
# Note that the opposite is not enforced for exits to allow fully optional layouts
if len(mission.prev) == 0:
mission.option_entrance = True
self.entrances.append(mission)
elif mission.option_entrance:
for prev_mission in mission.prev:
prev_mission.next.remove(mission)
mission.prev.clear()
if all_empty:
raise Exception(f"Layout \"{self.option_name}\" only contains empty mission slots.")
def is_beaten(self, beaten_missions: Set[SC2MOGenMission]) -> bool:
return beaten_missions.issuperset(self.exits)
def is_always_unlocked(self, in_region_creation = False) -> bool:
return self.entry_rule.is_always_fulfilled(in_region_creation)
def is_unlocked(self, beaten_missions: Set[SC2MOGenMission], in_region_creation = False) -> bool:
return self.entry_rule.is_fulfilled(beaten_missions, in_region_creation)
def resolve_index_term(self, term: Union[str, int], *, ignore_out_of_bounds: bool = True, reject_none: bool = True) -> Union[Set[int], None]:
try:
result = {int(term)}
except ValueError:
if term == "entrances":
result = {idx for idx in range(len(self.missions)) if self.missions[idx].option_entrance}
elif term == "exits":
result = {idx for idx in range(len(self.missions)) if self.missions[idx].option_exit}
elif term == "all":
result = {idx for idx in range(len(self.missions))}
else:
result = self.layout_type.parse_index(term)
if result is None and reject_none:
raise ValueError(f"Layout \"{self.option_name}\" could not resolve mission index term \"{term}\".")
if ignore_out_of_bounds:
result = [index for index in result if index >= 0 and index < len(self.missions)]
return result
def get_parent(self, _address_so_far: str, _full_address: str) -> MissionOrderNode:
if self.parent().option_single_layout_campaign:
parent = self.parent().parent
else:
parent = self.parent
return parent()
def search(self, term: str) -> Union[List[MissionOrderNode], None]:
indices = self.resolve_index_term(term, reject_none=False)
if indices is None:
# Let the address parser handle the fail case
return []
missions = [self.missions[index] for index in sorted(indices)]
return missions
def child_type_name(self) -> str:
return "Mission"
def get_missions(self) -> List[SC2MOGenMission]:
return [mission for mission in self.missions]
def get_exits(self) -> List[SC2MOGenMission]:
return self.exits
def get_visual_requirement(self, start_node: MissionOrderNode) -> Union[str, SC2MOGenMission]:
visual_name = self.get_visual_name()
if start_node.get_parent("", "") is self and visual_name == "":
return "this questline"
return visual_name
def get_visual_name(self) -> str:
return self.display_name
def get_key_name(self) -> str:
return item_names._TEMPLATE_NAMED_LAYOUT_KEY.format(self.get_visual_name(), self.parent().get_visual_name())
def get_min_depth(self) -> int:
return self.min_depth
def get_address_to_node(self) -> str:
campaign = self.parent()
if campaign.option_single_layout_campaign:
return f"{self.option_name}"
return self.parent().get_address_to_node() + f"/{self.option_name}"
def get_slot_data(self) -> LayoutSlotData:
mission_slots = [
[
asdict(self.missions[idx].get_slot_data() if (idx >= 0 and not self.missions[idx].option_empty) else MissionSlotData.empty())
for idx in column
]
for column in self.layout_type.get_visual_layout()
]
if self.important_beat_event:
exits = [slot.mission.id for slot in self.exits]
else:
exits = []
return LayoutSlotData(
self.get_visual_name(),
asdict(self.entry_rule.to_slot_data()),
exits,
mission_slots
)
class SC2MOGenMission(MissionOrderNode):
option_goal: bool # whether this mission is required to beat the game
option_entrance: bool # whether this mission is unlocked when the layout is unlocked
option_exit: bool # whether this mission is required to beat its parent layout
option_empty: bool # whether this slot contains a mission at all
option_next: Union[None, List[Union[int, str]]] # indices of internally connected missions
option_entry_rules: List[Dict[str, Any]]
option_difficulty: Difficulty # difficulty pool this mission pulls from
option_mission_pool: Set[int] # Allowed mission IDs for this slot
option_victory_cache: int # Number of victory cache locations tied to the mission name
entry_rule: SubRuleEntryRule
min_depth: int # Smallest amount of missions to beat before this slot is accessible
mission: SC2Mission
region: Region
next: List[SC2MOGenMission]
prev: List[SC2MOGenMission]
def __init__(self, parent: ReferenceType[SC2MOGenLayout], parent_mission_pool: Set[int]):
self.parent: ReferenceType[SC2MOGenLayout] = parent
self.important_beat_event = False
self.option_mission_pool = parent_mission_pool
self.option_goal = False
self.option_entrance = False
self.option_exit = False
self.option_empty = False
self.option_next = None
self.option_entry_rules = []
self.option_difficulty = Difficulty.RELATIVE
self.next = []
self.prev = []
self.min_depth = -1
self.option_victory_cache = -1
def update_with_data(self, data: Dict):
self.option_goal = data.get("goal", self.option_goal)
self.option_entrance = data.get("entrance", self.option_entrance)
self.option_exit = data.get("exit", self.option_exit)
self.option_empty = data.get("empty", self.option_empty)
self.option_next = data.get("next", self.option_next)
self.option_entry_rules = data.get("entry_rules", self.option_entry_rules)
self.option_difficulty = data.get("difficulty", self.option_difficulty)
self.option_mission_pool = data.get("mission_pool", self.option_mission_pool)
self.option_victory_cache = data.get("victory_cache", -1)
def is_always_unlocked(self, in_region_creation = False) -> bool:
return self.entry_rule.is_always_fulfilled(in_region_creation)
def is_unlocked(self, beaten_missions: Set[SC2MOGenMission], in_region_creation = False) -> bool:
return self.entry_rule.is_fulfilled(beaten_missions, in_region_creation)
def beat_item(self) -> str:
return f"Beat {self.mission.mission_name}"
def beat_rule(self, player) -> Callable[[CollectionState], bool]:
return lambda state: state.has(self.beat_item(), player)
def search(self, term: str) -> Union[List[MissionOrderNode], None]:
return None
def child_type_name(self) -> str:
return ""
def get_missions(self) -> List[SC2MOGenMission]:
return [self]
def get_exits(self) -> List[SC2MOGenMission]:
return [self]
def get_visual_requirement(self, _start_node: MissionOrderNode) -> Union[str, SC2MOGenMission]:
return self
def get_key_name(self) -> str:
return item_names._TEMPLATE_MISSION_KEY.format(self.mission.mission_name)
def get_min_depth(self) -> int:
return self.min_depth
def get_address_to_node(self) -> str:
layout = self.parent()
assert layout is not None
index = layout.missions.index(self)
return layout.get_address_to_node() + f"/{index}"
def get_slot_data(self) -> MissionSlotData:
return MissionSlotData(
self.mission.id,
[mission.mission.id for mission in self.prev],
self.entry_rule.to_slot_data(),
self.option_victory_cache,
)