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
This commit is contained in:
66
worlds/sc2/mission_order/__init__.py
Normal file
66
worlds/sc2/mission_order/__init__.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from typing import List, Dict, Any, Callable, TYPE_CHECKING
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
from ..mission_tables import SC2Mission, MissionFlag, get_goal_location
|
||||
from .mission_pools import SC2MOGenMissionPools
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .nodes import SC2MOGenMissionOrder, SC2MOGenMission
|
||||
|
||||
class SC2MissionOrder:
|
||||
"""
|
||||
Wrapper class for a generated mission order. Contains helper functions for getting data about generated missions.
|
||||
"""
|
||||
|
||||
def __init__(self, mission_order_node: 'SC2MOGenMissionOrder', mission_pools: SC2MOGenMissionPools):
|
||||
self.mission_order_node: 'SC2MOGenMissionOrder' = mission_order_node
|
||||
"""Root node of the mission order structure."""
|
||||
self.mission_pools: SC2MOGenMissionPools = mission_pools
|
||||
"""Manager for missions in the mission order."""
|
||||
|
||||
def get_used_flags(self) -> Dict[MissionFlag, int]:
|
||||
"""Returns a dictionary of all used flags and their appearance count within the mission order.
|
||||
Flags that don't appear in the mission order also don't appear in this dictionary."""
|
||||
return self.mission_pools.get_used_flags()
|
||||
|
||||
def get_used_missions(self) -> List[SC2Mission]:
|
||||
"""Returns a list of all missions used in the mission order."""
|
||||
return self.mission_pools.get_used_missions()
|
||||
|
||||
def get_mission_count(self) -> int:
|
||||
"""Returns the amount of missions in the mission order."""
|
||||
return sum(
|
||||
len([mission for mission in layout.missions if not mission.option_empty])
|
||||
for campaign in self.mission_order_node.campaigns for layout in campaign.layouts
|
||||
)
|
||||
|
||||
def get_starting_missions(self) -> List[SC2Mission]:
|
||||
"""Returns a list containing all the missions that are accessible without beating any other missions."""
|
||||
return [
|
||||
slot.mission
|
||||
for campaign in self.mission_order_node.campaigns if campaign.is_always_unlocked()
|
||||
for layout in campaign.layouts if layout.is_always_unlocked()
|
||||
for slot in layout.missions if slot.is_always_unlocked() and not slot.option_empty
|
||||
]
|
||||
|
||||
def get_completion_condition(self, player: int) -> Callable[[CollectionState], bool]:
|
||||
"""Returns a lambda to determine whether a state has beaten the mission order's required campaigns."""
|
||||
final_locations = [get_goal_location(mission.mission) for mission in self.get_final_missions()]
|
||||
return lambda state, final_locations=final_locations: all(state.can_reach_location(loc, player) for loc in final_locations)
|
||||
|
||||
def get_final_mission_ids(self) -> List[int]:
|
||||
"""Returns the IDs of all missions that are required to beat the mission order."""
|
||||
return [mission.mission.id for mission in self.get_final_missions()]
|
||||
|
||||
def get_final_missions(self) -> List['SC2MOGenMission']:
|
||||
"""Returns the slots of all missions that are required to beat the mission order."""
|
||||
return self.mission_order_node.goal_missions
|
||||
|
||||
def get_items_to_lock(self) -> Dict[str, int]:
|
||||
"""Returns a dict of item names and amounts that are required by Item entry rules."""
|
||||
return self.mission_order_node.items_to_lock
|
||||
|
||||
def get_slot_data(self) -> List[Dict[str, Any]]:
|
||||
"""Parses the mission order into a format usable for slot data."""
|
||||
return self.mission_order_node.get_slot_data()
|
||||
|
||||
389
worlds/sc2/mission_order/entry_rules.py
Normal file
389
worlds/sc2/mission_order/entry_rules.py
Normal file
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
from typing import Set, Callable, Dict, List, Union, TYPE_CHECKING, Any, NamedTuple
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..mission_tables import SC2Mission
|
||||
from ..item.item_tables import item_table
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .nodes import SC2MOGenMission
|
||||
|
||||
|
||||
class EntryRule(ABC):
|
||||
buffer_fulfilled: bool
|
||||
buffer_depth: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer_fulfilled = False
|
||||
self.buffer_depth = -1
|
||||
|
||||
def is_always_fulfilled(self, in_region_creation: bool = False) -> bool:
|
||||
return self.is_fulfilled(set(), in_region_creation)
|
||||
|
||||
@abstractmethod
|
||||
def _is_fulfilled(self, beaten_missions: Set[SC2MOGenMission], in_region_creation: bool) -> bool:
|
||||
"""Used during region creation to ensure a beatable mission order.
|
||||
|
||||
`in_region_creation` should determine whether rules that cannot be handled during region creation (like Item rules)
|
||||
report themselves as fulfilled or unfulfilled."""
|
||||
return False
|
||||
|
||||
def is_fulfilled(self, beaten_missions: Set[SC2MOGenMission], in_region_creation: bool) -> bool:
|
||||
if len(beaten_missions) == 0:
|
||||
# Special-cased to avoid the buffer
|
||||
# This is used to determine starting missions
|
||||
return self._is_fulfilled(beaten_missions, in_region_creation)
|
||||
self.buffer_fulfilled = self.buffer_fulfilled or self._is_fulfilled(beaten_missions, in_region_creation)
|
||||
return self.buffer_fulfilled
|
||||
|
||||
@abstractmethod
|
||||
def _get_depth(self, beaten_missions: Set[SC2MOGenMission]) -> int:
|
||||
"""Used during region creation to determine the minimum depth this entry rule can be cleared at."""
|
||||
return -1
|
||||
|
||||
def get_depth(self, beaten_missions: Set[SC2MOGenMission]) -> int:
|
||||
if not self.is_fulfilled(beaten_missions, in_region_creation = True):
|
||||
return -1
|
||||
if self.buffer_depth == -1:
|
||||
self.buffer_depth = self._get_depth(beaten_missions)
|
||||
return self.buffer_depth
|
||||
|
||||
@abstractmethod
|
||||
def to_lambda(self, player: int) -> Callable[[CollectionState], bool]:
|
||||
"""Passed to Archipelago for use during item placement."""
|
||||
return lambda _: False
|
||||
|
||||
@abstractmethod
|
||||
def to_slot_data(self) -> RuleData:
|
||||
"""Used in the client to determine accessibility while playing and to populate tooltips."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleData(ABC):
|
||||
@abstractmethod
|
||||
def tooltip(self, indents: int, missions: Dict[int, SC2Mission], done_color: str, not_done_color: str) -> str:
|
||||
return ""
|
||||
|
||||
@abstractmethod
|
||||
def shows_single_rule(self) -> bool:
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def is_accessible(
|
||||
self, beaten_missions: Set[int], received_items: Dict[int, int]
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class BeatMissionsEntryRule(EntryRule):
|
||||
missions_to_beat: List[SC2MOGenMission]
|
||||
visual_reqs: List[Union[str, SC2MOGenMission]]
|
||||
|
||||
def __init__(self, missions_to_beat: List[SC2MOGenMission], visual_reqs: List[Union[str, SC2MOGenMission]]):
|
||||
super().__init__()
|
||||
self.missions_to_beat = missions_to_beat
|
||||
self.visual_reqs = visual_reqs
|
||||
|
||||
def _is_fulfilled(self, beaten_missions: Set[SC2MOGenMission], in_region_check: bool) -> bool:
|
||||
return beaten_missions.issuperset(self.missions_to_beat)
|
||||
|
||||
def _get_depth(self, beaten_missions: Set[SC2MOGenMission]) -> int:
|
||||
return max(mission.min_depth for mission in self.missions_to_beat)
|
||||
|
||||
def to_lambda(self, player: int) -> Callable[[CollectionState], bool]:
|
||||
return lambda state: state.has_all([mission.beat_item() for mission in self.missions_to_beat], player)
|
||||
|
||||
def to_slot_data(self) -> RuleData:
|
||||
resolved_reqs: List[Union[str, int]] = [req if isinstance(req, str) else req.mission.id for req in self.visual_reqs]
|
||||
mission_ids = [mission.mission.id for mission in self.missions_to_beat]
|
||||
return BeatMissionsRuleData(
|
||||
mission_ids,
|
||||
resolved_reqs
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeatMissionsRuleData(RuleData):
|
||||
mission_ids: List[int]
|
||||
visual_reqs: List[Union[str, int]]
|
||||
|
||||
def tooltip(self, indents: int, missions: Dict[int, SC2Mission], done_color: str, not_done_color: str) -> str:
|
||||
indent = " ".join("" for _ in range(indents))
|
||||
if len(self.visual_reqs) == 1:
|
||||
req = self.visual_reqs[0]
|
||||
return f"Beat {missions[req].mission_name if isinstance(req, int) else req}"
|
||||
tooltip = f"Beat all of these:\n{indent}- "
|
||||
reqs = [missions[req].mission_name if isinstance(req, int) else req for req in self.visual_reqs]
|
||||
tooltip += f"\n{indent}- ".join(req for req in reqs)
|
||||
return tooltip
|
||||
|
||||
def shows_single_rule(self) -> bool:
|
||||
return len(self.visual_reqs) == 1
|
||||
|
||||
def is_accessible(
|
||||
self, beaten_missions: Set[int], received_items: Dict[int, int]
|
||||
) -> bool:
|
||||
# Beat rules are accessible if all their missions are beaten and accessible
|
||||
if not beaten_missions.issuperset(self.mission_ids):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class CountMissionsEntryRule(EntryRule):
|
||||
missions_to_count: List[SC2MOGenMission]
|
||||
target_amount: int
|
||||
visual_reqs: List[Union[str, SC2MOGenMission]]
|
||||
|
||||
def __init__(self, missions_to_count: List[SC2MOGenMission], target_amount: int, visual_reqs: List[Union[str, SC2MOGenMission]]):
|
||||
super().__init__()
|
||||
self.missions_to_count = missions_to_count
|
||||
if target_amount == -1 or target_amount > len(missions_to_count):
|
||||
self.target_amount = len(missions_to_count)
|
||||
else:
|
||||
self.target_amount = target_amount
|
||||
self.visual_reqs = visual_reqs
|
||||
|
||||
def _is_fulfilled(self, beaten_missions: Set[SC2MOGenMission], in_region_check: bool) -> bool:
|
||||
return self.target_amount <= len(beaten_missions.intersection(self.missions_to_count))
|
||||
|
||||
def _get_depth(self, beaten_missions: Set[SC2MOGenMission]) -> int:
|
||||
sorted_missions = sorted(beaten_missions.intersection(self.missions_to_count), key = lambda mission: mission.min_depth)
|
||||
mission_depth = max(mission.min_depth for mission in sorted_missions[:self.target_amount])
|
||||
return max(mission_depth, self.target_amount - 1) # -1 because depth is zero-based but amount is one-based
|
||||
|
||||
def to_lambda(self, player: int) -> Callable[[CollectionState], bool]:
|
||||
return lambda state: self.target_amount <= sum(state.has(mission.beat_item(), player) for mission in self.missions_to_count)
|
||||
|
||||
def to_slot_data(self) -> RuleData:
|
||||
resolved_reqs: List[Union[str, int]] = [req if isinstance(req, str) else req.mission.id for req in self.visual_reqs]
|
||||
mission_ids = [mission.mission.id for mission in sorted(self.missions_to_count, key = lambda mission: mission.min_depth)]
|
||||
return CountMissionsRuleData(
|
||||
mission_ids,
|
||||
self.target_amount,
|
||||
resolved_reqs
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CountMissionsRuleData(RuleData):
|
||||
mission_ids: List[int]
|
||||
amount: int
|
||||
visual_reqs: List[Union[str, int]]
|
||||
|
||||
def tooltip(self, indents: int, missions: Dict[int, SC2Mission], done_color: str, not_done_color: str) -> str:
|
||||
indent = " ".join("" for _ in range(indents))
|
||||
if self.amount == len(self.mission_ids):
|
||||
amount = "all"
|
||||
else:
|
||||
amount = str(self.amount)
|
||||
if len(self.visual_reqs) == 1:
|
||||
req = self.visual_reqs[0]
|
||||
req_str = missions[req].mission_name if isinstance(req, int) else req
|
||||
if self.amount == 1:
|
||||
if type(req) == int:
|
||||
return f"Beat {req_str}"
|
||||
return f"Beat any mission from {req_str}"
|
||||
return f"Beat {amount} missions from {req_str}"
|
||||
if self.amount == 1:
|
||||
tooltip = f"Beat any mission from:\n{indent}- "
|
||||
else:
|
||||
tooltip = f"Beat {amount} missions from:\n{indent}- "
|
||||
reqs = [missions[req].mission_name if isinstance(req, int) else req for req in self.visual_reqs]
|
||||
tooltip += f"\n{indent}- ".join(req for req in reqs)
|
||||
return tooltip
|
||||
|
||||
def shows_single_rule(self) -> bool:
|
||||
return len(self.visual_reqs) == 1
|
||||
|
||||
def is_accessible(
|
||||
self, beaten_missions: Set[int], received_items: Dict[int, int]
|
||||
) -> bool:
|
||||
# Count rules are accessible if enough of their missions are beaten and accessible
|
||||
return len([mission_id for mission_id in self.mission_ids if mission_id in beaten_missions]) >= self.amount
|
||||
|
||||
|
||||
class SubRuleEntryRule(EntryRule):
|
||||
rule_id: int
|
||||
rules_to_check: List[EntryRule]
|
||||
target_amount: int
|
||||
min_depth: int
|
||||
|
||||
def __init__(self, rules_to_check: List[EntryRule], target_amount: int, rule_id: int):
|
||||
super().__init__()
|
||||
self.rule_id = rule_id
|
||||
self.rules_to_check = rules_to_check
|
||||
self.min_depth = -1
|
||||
if target_amount == -1 or target_amount > len(rules_to_check):
|
||||
self.target_amount = len(rules_to_check)
|
||||
else:
|
||||
self.target_amount = target_amount
|
||||
|
||||
def _is_fulfilled(self, beaten_missions: Set[SC2MOGenMission], in_region_check: bool) -> bool:
|
||||
return self.target_amount <= sum(rule.is_fulfilled(beaten_missions, in_region_check) for rule in self.rules_to_check)
|
||||
|
||||
def _get_depth(self, beaten_missions: Set[SC2MOGenMission]) -> int:
|
||||
if len(self.rules_to_check) == 0:
|
||||
return self.min_depth
|
||||
# It should be guaranteed by is_fulfilled that enough rules have a valid depth because they are fulfilled
|
||||
filtered_rules = [rule for rule in self.rules_to_check if rule.get_depth(beaten_missions) > -1]
|
||||
sorted_rules = sorted(filtered_rules, key = lambda rule: rule.get_depth(beaten_missions))
|
||||
required_depth = max(rule.get_depth(beaten_missions) for rule in sorted_rules[:self.target_amount])
|
||||
return max(required_depth, self.min_depth)
|
||||
|
||||
def to_lambda(self, player: int) -> Callable[[CollectionState], bool]:
|
||||
sub_lambdas = [rule.to_lambda(player) for rule in self.rules_to_check]
|
||||
return lambda state, sub_lambdas=sub_lambdas: self.target_amount <= sum(sub_lambda(state) for sub_lambda in sub_lambdas)
|
||||
|
||||
def to_slot_data(self) -> SubRuleRuleData:
|
||||
sub_rules = [rule.to_slot_data() for rule in self.rules_to_check]
|
||||
return SubRuleRuleData(
|
||||
self.rule_id,
|
||||
sub_rules,
|
||||
self.target_amount
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubRuleRuleData(RuleData):
|
||||
rule_id: int
|
||||
sub_rules: List[RuleData]
|
||||
amount: int
|
||||
|
||||
@staticmethod
|
||||
def parse_from_dict(data: Dict[str, Any]) -> SubRuleRuleData:
|
||||
amount = data["amount"]
|
||||
rule_id = data["rule_id"]
|
||||
sub_rules: List[RuleData] = []
|
||||
for rule_data in data["sub_rules"]:
|
||||
if "sub_rules" in rule_data:
|
||||
rule: RuleData = SubRuleRuleData.parse_from_dict(rule_data)
|
||||
elif "item_ids" in rule_data:
|
||||
# Slot data converts Dict[int, int] to Dict[str, int] for some reason
|
||||
item_ids = {int(item): item_amount for (item, item_amount) in rule_data["item_ids"].items()}
|
||||
rule = ItemRuleData(
|
||||
item_ids,
|
||||
rule_data["visual_reqs"]
|
||||
)
|
||||
elif "amount" in rule_data:
|
||||
rule = CountMissionsRuleData(
|
||||
**{field: value for field, value in rule_data.items()}
|
||||
)
|
||||
else:
|
||||
rule = BeatMissionsRuleData(
|
||||
**{field: value for field, value in rule_data.items()}
|
||||
)
|
||||
sub_rules.append(rule)
|
||||
rule = SubRuleRuleData(
|
||||
rule_id,
|
||||
sub_rules,
|
||||
amount
|
||||
)
|
||||
return rule
|
||||
|
||||
@staticmethod
|
||||
def empty() -> SubRuleRuleData:
|
||||
return SubRuleRuleData(-1, [], 0)
|
||||
|
||||
def tooltip(self, indents: int, missions: Dict[int, SC2Mission], done_color: str, not_done_color: str) -> str:
|
||||
indent = " ".join("" for _ in range(indents))
|
||||
if self.amount == len(self.sub_rules):
|
||||
if self.amount == 1:
|
||||
return self.sub_rules[0].tooltip(indents, missions, done_color, not_done_color)
|
||||
amount = "all"
|
||||
elif self.amount == 1:
|
||||
amount = "any"
|
||||
else:
|
||||
amount = str(self.amount)
|
||||
tooltip = f"Fulfill {amount} of these conditions:\n{indent}- "
|
||||
subrule_tooltips: List[str] = []
|
||||
for rule in self.sub_rules:
|
||||
sub_tooltip = rule.tooltip(indents + 4, missions, done_color, not_done_color)
|
||||
if getattr(rule, "was_accessible", False):
|
||||
subrule_tooltips.append(f"[color={done_color}]{sub_tooltip}[/color]")
|
||||
else:
|
||||
subrule_tooltips.append(f"[color={not_done_color}]{sub_tooltip}[/color]")
|
||||
tooltip += f"\n{indent}- ".join(sub_tooltip for sub_tooltip in subrule_tooltips)
|
||||
return tooltip
|
||||
|
||||
def shows_single_rule(self) -> bool:
|
||||
return self.amount == len(self.sub_rules) == 1 and self.sub_rules[0].shows_single_rule()
|
||||
|
||||
def is_accessible(
|
||||
self, beaten_missions: Set[int], received_items: Dict[int, int]
|
||||
) -> bool:
|
||||
# Sub-rule rules are accessible if enough of their child rules are accessible
|
||||
accessible_count = 0
|
||||
success = accessible_count >= self.amount
|
||||
if self.amount > 0:
|
||||
for rule in self.sub_rules:
|
||||
if rule.is_accessible(beaten_missions, received_items):
|
||||
rule.was_accessible = True
|
||||
accessible_count += 1
|
||||
if accessible_count >= self.amount:
|
||||
success = True
|
||||
break
|
||||
else:
|
||||
rule.was_accessible = False
|
||||
|
||||
return success
|
||||
|
||||
class MissionEntryRules(NamedTuple):
|
||||
mission_rule: SubRuleRuleData
|
||||
layout_rule: SubRuleRuleData
|
||||
campaign_rule: SubRuleRuleData
|
||||
|
||||
|
||||
class ItemEntryRule(EntryRule):
|
||||
items_to_check: Dict[str, int]
|
||||
|
||||
def __init__(self, items_to_check: Dict[str, int]) -> None:
|
||||
super().__init__()
|
||||
self.items_to_check = items_to_check
|
||||
|
||||
def _is_fulfilled(self, beaten_missions: Set[SC2MOGenMission], in_region_check: bool) -> bool:
|
||||
# Region creation should assume items can be placed,
|
||||
# but later uses (eg. starter missions) should respect that this locks a mission
|
||||
return in_region_check
|
||||
|
||||
def _get_depth(self, beaten_missions: Set[SC2MOGenMission]) -> int:
|
||||
# Depth 0 means this rule requires 0 prior beaten missions
|
||||
return 0
|
||||
|
||||
def to_lambda(self, player: int) -> Callable[[CollectionState], bool]:
|
||||
return lambda state: state.has_all_counts(self.items_to_check, player)
|
||||
|
||||
def to_slot_data(self) -> RuleData:
|
||||
item_ids = {item_table[item].code: amount for (item, amount) in self.items_to_check.items()}
|
||||
visual_reqs = [item if amount == 1 else str(amount) + "x " + item for (item, amount) in self.items_to_check.items()]
|
||||
return ItemRuleData(
|
||||
item_ids,
|
||||
visual_reqs
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemRuleData(RuleData):
|
||||
item_ids: Dict[int, int]
|
||||
visual_reqs: List[str]
|
||||
|
||||
def tooltip(self, indents: int, missions: Dict[int, SC2Mission], done_color: str, not_done_color: str) -> str:
|
||||
indent = " ".join("" for _ in range(indents))
|
||||
if len(self.visual_reqs) == 1:
|
||||
return f"Find {self.visual_reqs[0]}"
|
||||
tooltip = f"Find all of these:\n{indent}- "
|
||||
tooltip += f"\n{indent}- ".join(req for req in self.visual_reqs)
|
||||
return tooltip
|
||||
|
||||
def shows_single_rule(self) -> bool:
|
||||
return len(self.visual_reqs) == 1
|
||||
|
||||
def is_accessible(
|
||||
self, beaten_missions: Set[int], received_items: Dict[int, int]
|
||||
) -> bool:
|
||||
return all(
|
||||
item in received_items and received_items[item] >= amount
|
||||
for (item, amount) in self.item_ids.items()
|
||||
)
|
||||
702
worlds/sc2/mission_order/generation.py
Normal file
702
worlds/sc2/mission_order/generation.py
Normal file
@@ -0,0 +1,702 @@
|
||||
"""
|
||||
Contains the complex data manipulation functions for mission order generation and Archipelago region creation.
|
||||
Incoming data is validated to match specifications in .options.py.
|
||||
The functions here are called from ..regions.py.
|
||||
"""
|
||||
|
||||
from typing import Set, Dict, Any, List, Tuple, Union, Optional, Callable, TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
from BaseClasses import Location, Region, Entrance
|
||||
from ..mission_tables import SC2Mission, MissionFlag, lookup_name_to_mission, lookup_id_to_mission
|
||||
from ..item.item_tables import named_layout_key_item_table, named_campaign_key_item_table
|
||||
from ..item import item_names
|
||||
from .nodes import MissionOrderNode, SC2MOGenMissionOrder, SC2MOGenCampaign, SC2MOGenLayout, SC2MOGenMission
|
||||
from .entry_rules import EntryRule, SubRuleEntryRule, ItemEntryRule, CountMissionsEntryRule, BeatMissionsEntryRule
|
||||
from .mission_pools import (
|
||||
SC2MOGenMissionPools, Difficulty, modified_difficulty_thresholds, STANDARD_DIFFICULTY_FILL_ORDER
|
||||
)
|
||||
from .options import GENERIC_KEY_NAME, GENERIC_PROGRESSIVE_KEY_NAME
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..locations import LocationData
|
||||
from .. import SC2World
|
||||
|
||||
def resolve_unlocks(mission_order: SC2MOGenMissionOrder):
|
||||
"""Parses a mission order's entry rule dicts into entry rule objects."""
|
||||
rolling_rule_id = 0
|
||||
for campaign in mission_order.campaigns:
|
||||
entry_rule = {
|
||||
"rules": campaign.option_entry_rules,
|
||||
"amount": -1
|
||||
}
|
||||
campaign.entry_rule = dict_to_entry_rule(mission_order, entry_rule, campaign, rolling_rule_id)
|
||||
rolling_rule_id += 1
|
||||
for layout in campaign.layouts:
|
||||
entry_rule = {
|
||||
"rules": layout.option_entry_rules,
|
||||
"amount": -1
|
||||
}
|
||||
layout.entry_rule = dict_to_entry_rule(mission_order, entry_rule, layout, rolling_rule_id)
|
||||
rolling_rule_id += 1
|
||||
for mission in layout.missions:
|
||||
entry_rule = {
|
||||
"rules": mission.option_entry_rules,
|
||||
"amount": -1
|
||||
}
|
||||
mission.entry_rule = dict_to_entry_rule(mission_order, entry_rule, mission, rolling_rule_id)
|
||||
rolling_rule_id += 1
|
||||
# Manually make a rule for prev missions
|
||||
if len(mission.prev) > 0:
|
||||
mission.entry_rule.target_amount += 1
|
||||
mission.entry_rule.rules_to_check.append(CountMissionsEntryRule(mission.prev, 1, mission.prev))
|
||||
|
||||
|
||||
def dict_to_entry_rule(mission_order: SC2MOGenMissionOrder, data: Dict[str, Any], start_node: MissionOrderNode, rule_id: int = -1) -> EntryRule:
|
||||
"""Tries to create an entry rule object from an entry rule dict. The structure of these dicts is validated in .options.py."""
|
||||
if "items" in data:
|
||||
items: Dict[str, int] = data["items"]
|
||||
has_generic_key = False
|
||||
for (item, amount) in items.items():
|
||||
if item.casefold() == GENERIC_KEY_NAME or item.casefold().startswith(GENERIC_PROGRESSIVE_KEY_NAME):
|
||||
has_generic_key = True
|
||||
continue # Don't try to lock the generic key
|
||||
if item in mission_order.items_to_lock:
|
||||
# Lock the greatest required amount of each item
|
||||
mission_order.items_to_lock[item] = max(mission_order.items_to_lock[item], amount)
|
||||
else:
|
||||
mission_order.items_to_lock[item] = amount
|
||||
rule = ItemEntryRule(items)
|
||||
if has_generic_key:
|
||||
mission_order.keys_to_resolve.setdefault(start_node, []).append(rule)
|
||||
return rule
|
||||
if "rules" in data:
|
||||
rules = [dict_to_entry_rule(mission_order, subrule, start_node) for subrule in data["rules"]]
|
||||
return SubRuleEntryRule(rules, data["amount"], rule_id)
|
||||
if "scope" in data:
|
||||
objects: List[Tuple[MissionOrderNode, str]] = []
|
||||
for address in data["scope"]:
|
||||
resolved = resolve_address(mission_order, address, start_node)
|
||||
objects.extend((obj, address) for obj in resolved)
|
||||
visual_reqs = [obj.get_visual_requirement(start_node) for (obj, _) in objects]
|
||||
missions: List[SC2MOGenMission]
|
||||
if "amount" in data:
|
||||
missions = [mission for (obj, _) in objects for mission in obj.get_missions() if not mission.option_empty]
|
||||
if len(missions) == 0:
|
||||
raise ValueError(f"Count rule did not find any missions at scopes: {data['scope']}")
|
||||
return CountMissionsEntryRule(missions, data["amount"], visual_reqs)
|
||||
missions = []
|
||||
for (obj, address) in objects:
|
||||
obj.important_beat_event = True
|
||||
exits = obj.get_exits()
|
||||
if len(exits) == 0:
|
||||
raise ValueError(
|
||||
f"Address \"{address}\" found an unbeatable object. "
|
||||
"This should mean the address contains \"..\" too often."
|
||||
)
|
||||
missions.extend(exits)
|
||||
return BeatMissionsEntryRule(missions, visual_reqs)
|
||||
raise ValueError(f"Invalid data for entry rule: {data}")
|
||||
|
||||
|
||||
def resolve_address(mission_order: SC2MOGenMissionOrder, address: str, start_node: MissionOrderNode) -> List[MissionOrderNode]:
|
||||
"""Tries to find a node in the mission order by following the given address."""
|
||||
if address.startswith("../") or address == "..":
|
||||
# Relative address, starts from searching object
|
||||
cursor = start_node
|
||||
else:
|
||||
# Absolute address, starts from the top
|
||||
cursor = mission_order
|
||||
address_so_far = ""
|
||||
for term in address.split("/"):
|
||||
if len(address_so_far) > 0:
|
||||
address_so_far += "/"
|
||||
address_so_far += term
|
||||
if term == "..":
|
||||
cursor = cursor.get_parent(address_so_far, address)
|
||||
else:
|
||||
result = cursor.search(term)
|
||||
if result is None:
|
||||
raise ValueError(f"Address \"{address_so_far}\" (from \"{address}\") tried to find a child for a mission.")
|
||||
if len(result) == 0:
|
||||
raise ValueError(f"Address \"{address_so_far}\" (from \"{address}\") could not find a {cursor.child_type_name()}.")
|
||||
if len(result) > 1:
|
||||
# Layouts are allowed to end with multiple missions via an index function
|
||||
if type(result[0]) == SC2MOGenMission and address_so_far == address:
|
||||
return result
|
||||
raise ValueError((f"Address \"{address_so_far}\" (from \"{address}\") found more than one {cursor.child_type_name()}."))
|
||||
cursor = result[0]
|
||||
if cursor == start_node:
|
||||
raise ValueError(
|
||||
f"Address \"{address_so_far}\" (from \"{address}\") returned to original object. "
|
||||
"This is not allowed to avoid circular requirements."
|
||||
)
|
||||
return [cursor]
|
||||
|
||||
|
||||
########################
|
||||
|
||||
|
||||
def fill_depths(mission_order: SC2MOGenMissionOrder) -> None:
|
||||
"""
|
||||
Flood-fills the mission order by following its entry rules to determine the depth of all nodes.
|
||||
This also ensures theoretical total accessibility of all nodes, but this is allowed to be violated by item placement and the accessibility setting.
|
||||
"""
|
||||
accessible_campaigns: Set[SC2MOGenCampaign] = {campaign for campaign in mission_order.campaigns if campaign.is_always_unlocked(in_region_creation=True)}
|
||||
next_campaigns: Set[SC2MOGenCampaign] = set(mission_order.campaigns).difference(accessible_campaigns)
|
||||
|
||||
accessible_layouts: Set[SC2MOGenLayout] = {
|
||||
layout
|
||||
for campaign in accessible_campaigns for layout in campaign.layouts
|
||||
if layout.is_always_unlocked(in_region_creation=True)
|
||||
}
|
||||
next_layouts: Set[SC2MOGenLayout] = {layout for campaign in accessible_campaigns for layout in campaign.layouts}.difference(accessible_layouts)
|
||||
|
||||
next_missions: Set[SC2MOGenMission] = {mission for layout in accessible_layouts for mission in layout.entrances}
|
||||
beaten_missions: Set[SC2MOGenMission] = set()
|
||||
|
||||
# Sanity check: Can any missions be accessed?
|
||||
if len(next_missions) == 0:
|
||||
raise Exception("Mission order has no possibly accessible missions")
|
||||
|
||||
iterations = 0
|
||||
while len(next_missions) > 0:
|
||||
# Check for accessible missions
|
||||
cur_missions: Set[SC2MOGenMission] = {
|
||||
mission for mission in next_missions
|
||||
if mission.is_unlocked(beaten_missions, in_region_creation=True)
|
||||
}
|
||||
if len(cur_missions) == 0:
|
||||
raise Exception(f"Mission order ran out of accessible missions during iteration {iterations}")
|
||||
next_missions.difference_update(cur_missions)
|
||||
# Set the depth counters of all currently accessible missions
|
||||
new_beaten_missions: Set[SC2MOGenMission] = set()
|
||||
while len(cur_missions) > 0:
|
||||
mission = cur_missions.pop()
|
||||
new_beaten_missions.add(mission)
|
||||
# If the beaten missions at depth X unlock a mission, said mission can be beaten at depth X+1
|
||||
mission.min_depth = mission.entry_rule.get_depth(beaten_missions) + 1
|
||||
new_next = [
|
||||
next_mission for next_mission in mission.next if not (
|
||||
next_mission in cur_missions
|
||||
or next_mission in beaten_missions
|
||||
or next_mission in new_beaten_missions
|
||||
)
|
||||
]
|
||||
next_missions.update(new_next)
|
||||
|
||||
# Any campaigns/layouts/missions added after this point will be seen in the next iteration at the earliest
|
||||
iterations += 1
|
||||
beaten_missions.update(new_beaten_missions)
|
||||
|
||||
# Check for newly accessible campaigns & layouts
|
||||
new_campaigns: Set[SC2MOGenCampaign] = set()
|
||||
for campaign in next_campaigns:
|
||||
if campaign.is_unlocked(beaten_missions, in_region_creation=True):
|
||||
new_campaigns.add(campaign)
|
||||
for campaign in new_campaigns:
|
||||
accessible_campaigns.add(campaign)
|
||||
next_layouts.update(campaign.layouts)
|
||||
next_campaigns.remove(campaign)
|
||||
for layout in campaign.layouts:
|
||||
layout.entry_rule.min_depth = campaign.entry_rule.get_depth(beaten_missions)
|
||||
new_layouts: Set[SC2MOGenLayout] = set()
|
||||
for layout in next_layouts:
|
||||
if layout.is_unlocked(beaten_missions, in_region_creation=True):
|
||||
new_layouts.add(layout)
|
||||
for layout in new_layouts:
|
||||
accessible_layouts.add(layout)
|
||||
next_missions.update(layout.entrances)
|
||||
next_layouts.remove(layout)
|
||||
for mission in layout.entrances:
|
||||
mission.entry_rule.min_depth = layout.entry_rule.get_depth(beaten_missions)
|
||||
|
||||
# Make sure we didn't miss anything
|
||||
assert len(accessible_campaigns) == len(mission_order.campaigns)
|
||||
assert len(accessible_layouts) == sum(len(campaign.layouts) for campaign in mission_order.campaigns)
|
||||
total_missions = sum(
|
||||
len([mission for mission in layout.missions if not mission.option_empty])
|
||||
for campaign in mission_order.campaigns for layout in campaign.layouts
|
||||
)
|
||||
assert len(beaten_missions) == total_missions, f'Can only access {len(beaten_missions)} missions out of {total_missions}'
|
||||
|
||||
# Fill campaign/layout depth values as min/max of their children
|
||||
for campaign in mission_order.campaigns:
|
||||
for layout in campaign.layouts:
|
||||
depths = [mission.min_depth for mission in layout.missions if not mission.option_empty]
|
||||
layout.min_depth = min(depths)
|
||||
layout.max_depth = max(depths)
|
||||
campaign.min_depth = min(layout.min_depth for layout in campaign.layouts)
|
||||
campaign.max_depth = max(layout.max_depth for layout in campaign.layouts)
|
||||
mission_order.max_depth = max(campaign.max_depth for campaign in mission_order.campaigns)
|
||||
|
||||
|
||||
########################
|
||||
|
||||
|
||||
def resolve_difficulties(mission_order: SC2MOGenMissionOrder) -> None:
|
||||
"""Determines the concrete difficulty of all mission slots."""
|
||||
for campaign in mission_order.campaigns:
|
||||
for layout in campaign.layouts:
|
||||
if layout.option_min_difficulty == Difficulty.RELATIVE:
|
||||
min_diff = campaign.option_min_difficulty
|
||||
if min_diff == Difficulty.RELATIVE:
|
||||
min_depth = 0
|
||||
else:
|
||||
min_depth = campaign.min_depth
|
||||
else:
|
||||
min_diff = layout.option_min_difficulty
|
||||
min_depth = layout.min_depth
|
||||
|
||||
if layout.option_max_difficulty == Difficulty.RELATIVE:
|
||||
max_diff = campaign.option_max_difficulty
|
||||
if max_diff == Difficulty.RELATIVE:
|
||||
max_depth = mission_order.max_depth
|
||||
else:
|
||||
max_depth = campaign.max_depth
|
||||
else:
|
||||
max_diff = layout.option_max_difficulty
|
||||
max_depth = layout.max_depth
|
||||
|
||||
depth_range = max_depth - min_depth
|
||||
if depth_range == 0:
|
||||
# This can happen if layout size is 1 or layout is all entrances
|
||||
# Use minimum difficulty in this case
|
||||
depth_range = 1
|
||||
# If min/max aren't relative, assume the limits are meant to show up
|
||||
layout_thresholds = modified_difficulty_thresholds(min_diff, max_diff)
|
||||
thresholds = sorted(layout_thresholds.keys())
|
||||
|
||||
for mission in layout.missions:
|
||||
if mission.option_empty:
|
||||
continue
|
||||
if len(mission.option_mission_pool) == 1:
|
||||
mission_order.fixed_missions.append(mission)
|
||||
continue
|
||||
if mission.option_difficulty == Difficulty.RELATIVE:
|
||||
mission_thresh = int((mission.min_depth - min_depth) * 100 / depth_range)
|
||||
for i in range(len(thresholds)):
|
||||
if thresholds[i] > mission_thresh:
|
||||
mission.option_difficulty = layout_thresholds[thresholds[i - 1]]
|
||||
break
|
||||
mission.option_difficulty = layout_thresholds[thresholds[-1]]
|
||||
mission_order.sorted_missions[mission.option_difficulty].append(mission)
|
||||
|
||||
|
||||
########################
|
||||
|
||||
|
||||
def fill_missions(
|
||||
mission_order: SC2MOGenMissionOrder, mission_pools: SC2MOGenMissionPools,
|
||||
world: 'SC2World', locked_missions: List[str], locations: Tuple['LocationData', ...], location_cache: List[Location]
|
||||
) -> None:
|
||||
"""Places missions in all non-empty mission slots. Also responsible for creating Archipelago regions & locations for placed missions."""
|
||||
locations_per_region = get_locations_per_region(locations)
|
||||
regions: List[Region] = [create_region(world, locations_per_region, location_cache, "Menu")]
|
||||
locked_ids = [lookup_name_to_mission[mission].id for mission in locked_missions]
|
||||
prefer_close_difficulty = world.options.difficulty_curve.value == world.options.difficulty_curve.option_standard
|
||||
|
||||
def set_mission_in_slot(slot: SC2MOGenMission, mission: SC2Mission):
|
||||
slot.mission = mission
|
||||
slot.region = create_region(world, locations_per_region, location_cache,
|
||||
mission.mission_name, slot)
|
||||
|
||||
# Resolve slots with set mission names
|
||||
for mission_slot in mission_order.fixed_missions:
|
||||
mission_id = mission_slot.option_mission_pool.pop()
|
||||
# Remove set mission from locked missions
|
||||
locked_ids = [locked for locked in locked_ids if locked != mission_id]
|
||||
mission = lookup_id_to_mission[mission_id]
|
||||
if mission in mission_pools.get_used_missions():
|
||||
raise ValueError(f"Mission slot at address \"{mission_slot.get_address_to_node()}\" tried to plando an already plando'd mission.")
|
||||
mission_pools.pull_specific_mission(mission)
|
||||
set_mission_in_slot(mission_slot, mission)
|
||||
regions.append(mission_slot.region)
|
||||
|
||||
# Shuffle & sort all slots to pick from smallest to biggest pool with tie-breaks by difficulty (lowest to highest), then randomly
|
||||
# Additionally sort goals by difficulty (highest to lowest) with random tie-breaks
|
||||
sorted_goals: List[SC2MOGenMission] = []
|
||||
for difficulty in sorted(mission_order.sorted_missions.keys()):
|
||||
world.random.shuffle(mission_order.sorted_missions[difficulty])
|
||||
sorted_goals.extend(mission for mission in mission_order.sorted_missions[difficulty] if mission in mission_order.goal_missions)
|
||||
# Sort slots by difficulty, with difficulties sorted by fill order
|
||||
# standard curve/close difficulty fills difficulties out->in, uneven fills easy->hard
|
||||
if prefer_close_difficulty:
|
||||
all_slots = [slot for diff in STANDARD_DIFFICULTY_FILL_ORDER for slot in mission_order.sorted_missions[diff]]
|
||||
else:
|
||||
all_slots = [slot for diff in sorted(mission_order.sorted_missions.keys()) for slot in mission_order.sorted_missions[diff]]
|
||||
# Pick slots with a constrained mission pool first
|
||||
all_slots.sort(key = lambda slot: len(slot.option_mission_pool.intersection(mission_pools.master_list)))
|
||||
sorted_goals.reverse()
|
||||
|
||||
# Randomly assign locked missions to appropriate difficulties
|
||||
slots_for_locked: Dict[int, List[SC2MOGenMission]] = {locked: [] for locked in locked_ids}
|
||||
for mission_slot in all_slots:
|
||||
allowed_locked = mission_slot.option_mission_pool.intersection(locked_ids)
|
||||
for locked in allowed_locked:
|
||||
slots_for_locked[locked].append(mission_slot)
|
||||
for (locked, allowed_slots) in slots_for_locked.items():
|
||||
locked_mission = lookup_id_to_mission[locked]
|
||||
allowed_slots = [slot for slot in allowed_slots if slot in all_slots]
|
||||
if len(allowed_slots) == 0:
|
||||
logging.warning(f"SC2: Locked mission \"{locked_mission.mission_name}\" is not allowed in any remaining spot and will not be placed.")
|
||||
continue
|
||||
# This inherits the earlier sorting, but is now sorted again by relative difficulty
|
||||
# The result is a sorting in order of nearest difficulty (preferring lower), then by smallest pool, then randomly
|
||||
allowed_slots.sort(key = lambda slot: abs(slot.option_difficulty - locked_mission.pool + 1))
|
||||
# The first slot should be most appropriate
|
||||
mission_slot = allowed_slots[0]
|
||||
mission_pools.pull_specific_mission(locked_mission)
|
||||
set_mission_in_slot(mission_slot, locked_mission)
|
||||
regions.append(mission_slot.region)
|
||||
all_slots.remove(mission_slot)
|
||||
if mission_slot in sorted_goals:
|
||||
sorted_goals.remove(mission_slot)
|
||||
|
||||
# Pick goal missions first with stricter difficulty matching, and starting with harder goals
|
||||
for goal_slot in sorted_goals:
|
||||
try:
|
||||
mission = mission_pools.pull_random_mission(world, goal_slot, prefer_close_difficulty=True)
|
||||
set_mission_in_slot(goal_slot, mission)
|
||||
regions.append(goal_slot.region)
|
||||
all_slots.remove(goal_slot)
|
||||
except IndexError:
|
||||
raise IndexError(
|
||||
f"Slot at address \"{goal_slot.get_address_to_node()}\" ran out of possible missions to place "
|
||||
f"with {len(all_slots)} empty slots remaining."
|
||||
)
|
||||
|
||||
# Pick random missions
|
||||
remaining_count = len(all_slots)
|
||||
for mission_slot in all_slots:
|
||||
try:
|
||||
mission = mission_pools.pull_random_mission(world, mission_slot, prefer_close_difficulty=prefer_close_difficulty)
|
||||
set_mission_in_slot(mission_slot, mission)
|
||||
regions.append(mission_slot.region)
|
||||
remaining_count -= 1
|
||||
except IndexError:
|
||||
raise IndexError(
|
||||
f"Slot at address \"{mission_slot.get_address_to_node()}\" ran out of possible missions to place "
|
||||
f"with {remaining_count} empty slots remaining."
|
||||
)
|
||||
|
||||
world.multiworld.regions += regions
|
||||
|
||||
|
||||
def get_locations_per_region(locations: Tuple['LocationData', ...]) -> Dict[str, List['LocationData']]:
|
||||
per_region: Dict[str, List['LocationData']] = {}
|
||||
|
||||
for location in locations:
|
||||
per_region.setdefault(location.region, []).append(location)
|
||||
|
||||
return per_region
|
||||
|
||||
|
||||
def create_location(player: int, location_data: 'LocationData', region: Region,
|
||||
location_cache: List[Location]) -> Location:
|
||||
location = Location(player, location_data.name, location_data.code, region)
|
||||
location.access_rule = location_data.rule
|
||||
|
||||
location_cache.append(location)
|
||||
return location
|
||||
|
||||
|
||||
def create_minimal_logic_location(
|
||||
world: 'SC2World', location_data: 'LocationData', region: Region, location_cache: List[Location], unit_count: int = 0,
|
||||
) -> Location:
|
||||
location = Location(world.player, location_data.name, location_data.code, region)
|
||||
mission = lookup_name_to_mission.get(region.name)
|
||||
if mission is None:
|
||||
pass
|
||||
elif location_data.hard_rule:
|
||||
assert world.logic
|
||||
unit_rule = world.logic.has_race_units(unit_count, mission.race)
|
||||
location.access_rule = lambda state: unit_rule(state) and location_data.hard_rule(state)
|
||||
else:
|
||||
assert world.logic
|
||||
location.access_rule = world.logic.has_race_units(unit_count, mission.race)
|
||||
location_cache.append(location)
|
||||
return location
|
||||
|
||||
|
||||
def create_region(
|
||||
world: 'SC2World',
|
||||
locations_per_region: Dict[str, List['LocationData']],
|
||||
location_cache: List[Location],
|
||||
name: str,
|
||||
slot: Optional[SC2MOGenMission] = None,
|
||||
) -> Region:
|
||||
MAX_UNIT_REQUIREMENT = 5
|
||||
region = Region(name, world.player, world.multiworld)
|
||||
|
||||
from ..locations import LocationType
|
||||
if slot is None:
|
||||
target_victory_cache_locations = 0
|
||||
else:
|
||||
target_victory_cache_locations = slot.option_victory_cache
|
||||
victory_cache_locations = 0
|
||||
|
||||
# If the first mission is a build mission,
|
||||
# require a unit everywhere except one location in the easiest category
|
||||
mission_needs_unit = False
|
||||
unit_given = False
|
||||
easiest_category = LocationType.MASTERY
|
||||
if slot is not None and slot.min_depth == 0:
|
||||
mission = lookup_name_to_mission.get(region.name)
|
||||
if mission is not None and MissionFlag.NoBuild not in mission.flags:
|
||||
mission_needs_unit = True
|
||||
for location_data in locations_per_region.get(name, ()):
|
||||
if location_data.type == LocationType.VICTORY:
|
||||
pass
|
||||
elif location_data.type < easiest_category:
|
||||
easiest_category = location_data.type
|
||||
if easiest_category >= LocationType.CHALLENGE:
|
||||
easiest_category = LocationType.VICTORY
|
||||
|
||||
for location_data in locations_per_region.get(name, ()):
|
||||
assert slot is not None
|
||||
if location_data.type == LocationType.VICTORY_CACHE:
|
||||
if victory_cache_locations >= target_victory_cache_locations:
|
||||
continue
|
||||
victory_cache_locations += 1
|
||||
if world.options.required_tactics.value == world.options.required_tactics.option_any_units:
|
||||
if mission_needs_unit and not unit_given and location_data.type == easiest_category:
|
||||
# Ensure there is at least one no-logic location if the first mission is a build mission
|
||||
location = create_minimal_logic_location(world, location_data, region, location_cache, 0)
|
||||
unit_given = True
|
||||
elif location_data.type == LocationType.MASTERY:
|
||||
# Mastery locations always require max units regardless of position in the ramp
|
||||
location = create_minimal_logic_location(world, location_data, region, location_cache, MAX_UNIT_REQUIREMENT)
|
||||
else:
|
||||
# Required number of units = mission depth; +1 if it's a starting build mission; +1 if it's a challenge location
|
||||
location = create_minimal_logic_location(world, location_data, region, location_cache, min(
|
||||
slot.min_depth + mission_needs_unit + (location_data.type == LocationType.CHALLENGE),
|
||||
MAX_UNIT_REQUIREMENT
|
||||
))
|
||||
else:
|
||||
location = create_location(world.player, location_data, region, location_cache)
|
||||
region.locations.append(location)
|
||||
|
||||
return region
|
||||
|
||||
|
||||
########################
|
||||
|
||||
|
||||
def make_connections(mission_order: SC2MOGenMissionOrder, world: 'SC2World'):
|
||||
"""Creates Archipelago entrances between missions and creates access rules for the generator from entry rule objects."""
|
||||
names: Dict[str, int] = {}
|
||||
player = world.player
|
||||
for campaign in mission_order.campaigns:
|
||||
for layout in campaign.layouts:
|
||||
for mission in layout.missions:
|
||||
if not mission.option_empty:
|
||||
mission_rule = mission.entry_rule.to_lambda(player)
|
||||
# Only layout entrances need to consider campaign & layout prerequisites
|
||||
if mission.option_entrance:
|
||||
campaign_rule = mission.parent().parent().entry_rule.to_lambda(player)
|
||||
layout_rule = mission.parent().entry_rule.to_lambda(player)
|
||||
unlock_rule = lambda state, campaign_rule=campaign_rule, layout_rule=layout_rule, mission_rule=mission_rule: \
|
||||
campaign_rule(state) and layout_rule(state) and mission_rule(state)
|
||||
else:
|
||||
unlock_rule = mission_rule
|
||||
# Individually connect to previous missions
|
||||
for prev_mission in mission.prev:
|
||||
connect(world, names, prev_mission.mission.mission_name, mission.mission.mission_name,
|
||||
lambda state, unlock_rule=unlock_rule: unlock_rule(state))
|
||||
# If there are no previous missions, connect to Menu instead
|
||||
if len(mission.prev) == 0:
|
||||
connect(world, names, "Menu", mission.mission.mission_name,
|
||||
lambda state, unlock_rule=unlock_rule: unlock_rule(state))
|
||||
|
||||
|
||||
def connect(world: 'SC2World', used_names: Dict[str, int], source: str, target: str,
|
||||
rule: Optional[Callable] = None):
|
||||
source_region = world.get_region(source)
|
||||
target_region = world.get_region(target)
|
||||
|
||||
if target not in used_names:
|
||||
used_names[target] = 1
|
||||
name = target
|
||||
else:
|
||||
used_names[target] += 1
|
||||
name = target + (' ' * used_names[target])
|
||||
|
||||
connection = Entrance(world.player, name, source_region)
|
||||
|
||||
if rule:
|
||||
connection.access_rule = rule
|
||||
|
||||
source_region.exits.append(connection)
|
||||
connection.connect(target_region)
|
||||
|
||||
|
||||
########################
|
||||
|
||||
|
||||
def resolve_generic_keys(mission_order: SC2MOGenMissionOrder) -> None:
|
||||
"""
|
||||
Replaces placeholder keys in Item entry rules with their concrete counterparts.
|
||||
Specifically this handles placing named keys into missions and vanilla campaigns/layouts,
|
||||
and assigning correct progression tracks to progressive keys.
|
||||
"""
|
||||
layout_numbered_keys = 1
|
||||
campaign_numbered_keys = 1
|
||||
progression_tracks: Dict[int, List[Tuple[MissionOrderNode, ItemEntryRule]]] = {}
|
||||
for (node, item_rules) in mission_order.keys_to_resolve.items():
|
||||
key_name = node.get_key_name()
|
||||
# Generic keys in mission slots should always resolve to an existing key
|
||||
# Layouts and campaigns may need to be switched for numbered keys
|
||||
if isinstance(node, SC2MOGenLayout) and key_name not in named_layout_key_item_table:
|
||||
key_name = item_names._TEMPLATE_NUMBERED_LAYOUT_KEY.format(layout_numbered_keys)
|
||||
layout_numbered_keys += 1
|
||||
elif isinstance(node, SC2MOGenCampaign) and key_name not in named_campaign_key_item_table:
|
||||
key_name = item_names._TEMPLATE_NUMBERED_CAMPAIGN_KEY.format(campaign_numbered_keys)
|
||||
campaign_numbered_keys += 1
|
||||
|
||||
for item_rule in item_rules:
|
||||
# Swap regular generic key names for the node's proper key name
|
||||
item_rule.items_to_check = {
|
||||
key_name if item_name.casefold() == GENERIC_KEY_NAME else item_name: amount
|
||||
for (item_name, amount) in item_rule.items_to_check.items()
|
||||
}
|
||||
# Only lock the key if it was actually placed in this rule
|
||||
if key_name in item_rule.items_to_check:
|
||||
mission_order.items_to_lock[key_name] = max(item_rule.items_to_check[key_name], mission_order.items_to_lock.get(key_name, 0))
|
||||
|
||||
# Sort progressive keys by their given track
|
||||
for (item_name, amount) in item_rule.items_to_check.items():
|
||||
if item_name.casefold() == GENERIC_PROGRESSIVE_KEY_NAME:
|
||||
progression_tracks.setdefault(amount, []).append((node, item_rule))
|
||||
elif item_name.casefold().startswith(GENERIC_PROGRESSIVE_KEY_NAME):
|
||||
track_string = item_name.split()[-1]
|
||||
try:
|
||||
track = int(track_string)
|
||||
progression_tracks.setdefault(track, []).append((node, item_rule))
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Progression track \"{track_string}\" for progressive key \"{item_name}: {amount}\" is not a valid number. "
|
||||
"Valid formats are:\n"
|
||||
f"- {GENERIC_PROGRESSIVE_KEY_NAME.title()}: X\n"
|
||||
f"- {GENERIC_PROGRESSIVE_KEY_NAME.title()} X: 1"
|
||||
)
|
||||
|
||||
def find_progressive_keys(item_rule: ItemEntryRule, track_to_find: int) -> List[str]:
|
||||
return [
|
||||
item_name for (item_name, amount) in item_rule.items_to_check.items()
|
||||
if (item_name.casefold() == GENERIC_PROGRESSIVE_KEY_NAME and amount == track_to_find) or (
|
||||
item_name.casefold().startswith(GENERIC_PROGRESSIVE_KEY_NAME) and
|
||||
item_name.split()[-1] == str(track_to_find)
|
||||
)
|
||||
]
|
||||
|
||||
def replace_progressive_keys(item_rule: ItemEntryRule, track_to_replace: int, new_key_name: str, new_key_amount: int):
|
||||
keys_to_replace = find_progressive_keys(item_rule, track_to_replace)
|
||||
new_items_to_check: Dict[str, int] = {}
|
||||
for (item_name, amount) in item_rule.items_to_check.items():
|
||||
if item_name in keys_to_replace:
|
||||
new_items_to_check[new_key_name] = new_key_amount
|
||||
else:
|
||||
new_items_to_check[item_name] = amount
|
||||
item_rule.items_to_check = new_items_to_check
|
||||
|
||||
# Change progressive keys to be unique for missions and layouts that request it
|
||||
want_unique: Dict[MissionOrderNode, List[Tuple[MissionOrderNode, ItemEntryRule]]] = {}
|
||||
empty_tracks: List[int] = []
|
||||
for track in progression_tracks:
|
||||
# Sort keys to change by layout
|
||||
new_unique_tracks: Dict[MissionOrderNode, List[Tuple[MissionOrderNode, ItemEntryRule]]] = {}
|
||||
for (node, item_rule) in progression_tracks[track]:
|
||||
if isinstance(node, SC2MOGenMission):
|
||||
# Unique tracks for layouts take priority over campaigns
|
||||
if node.parent().option_unique_progression_track == track:
|
||||
new_unique_tracks.setdefault(node.parent(), []).append((node, item_rule))
|
||||
elif node.parent().parent().option_unique_progression_track == track:
|
||||
new_unique_tracks.setdefault(node.parent().parent(), []).append((node, item_rule))
|
||||
elif isinstance(node, SC2MOGenLayout) and node.parent().option_unique_progression_track == track:
|
||||
new_unique_tracks.setdefault(node.parent(), []).append((node, item_rule))
|
||||
# Remove found keys from their original progression track
|
||||
for (container_node, rule_list) in new_unique_tracks.items():
|
||||
for node_and_rule in rule_list:
|
||||
progression_tracks[track].remove(node_and_rule)
|
||||
want_unique.setdefault(container_node, []).extend(rule_list)
|
||||
if len(progression_tracks[track]) == 0:
|
||||
empty_tracks.append(track)
|
||||
for track in empty_tracks:
|
||||
progression_tracks.pop(track)
|
||||
|
||||
# Make sure all tracks that can't have keys have been taken care of
|
||||
invalid_tracks: List[int] = [track for track in progression_tracks if track < 1 or track > len(SC2Mission)]
|
||||
if len(invalid_tracks) > 0:
|
||||
affected_key_list: Dict[MissionOrderNode, List[str]] = {}
|
||||
for track in invalid_tracks:
|
||||
for (node, item_rule) in progression_tracks[track]:
|
||||
affected_key_list.setdefault(node, []).extend(
|
||||
f"{key}: {item_rule.items_to_check[key]}" for key in find_progressive_keys(item_rule, track)
|
||||
)
|
||||
affected_key_list_string = "\n- " + "\n- ".join(
|
||||
f"{node.get_address_to_node()}: {affected_keys}"
|
||||
for (node, affected_keys) in affected_key_list.items()
|
||||
)
|
||||
raise ValueError(
|
||||
"Some item rules contain progressive keys with invalid tracks:" +
|
||||
affected_key_list_string +
|
||||
f"\nPossible solutions are changing the tracks of affected keys to be in the range from 1 to {len(SC2Mission)}, "
|
||||
"or changing the unique_progression_track of containing campaigns/layouts to match the invalid tracks."
|
||||
)
|
||||
|
||||
# Assign new free progression tracks to nodes in definition order
|
||||
next_free = 1
|
||||
nodes_to_assign = list(want_unique.keys())
|
||||
while len(want_unique) > 0:
|
||||
while next_free in progression_tracks:
|
||||
next_free += 1
|
||||
container_node = nodes_to_assign.pop(0)
|
||||
progression_tracks[next_free] = want_unique.pop(container_node)
|
||||
# Replace the affected keys in nodes with their correct counterparts
|
||||
key_name = f"{GENERIC_PROGRESSIVE_KEY_NAME} {next_free}"
|
||||
for (node, item_rule) in progression_tracks[next_free]:
|
||||
# It's guaranteed by the sorting above that the container is either a layout or a campaign
|
||||
replace_progressive_keys(item_rule, container_node.option_unique_progression_track, key_name, 1)
|
||||
|
||||
# Give progressive keys a more fitting name if there's only one track and they all apply to the same type of node
|
||||
progressive_flavor_name: Union[str, None] = None
|
||||
if len(progression_tracks) == 1:
|
||||
if all(isinstance(node, SC2MOGenLayout) for rule_list in progression_tracks.values() for (node, _) in rule_list):
|
||||
progressive_flavor_name = item_names.PROGRESSIVE_QUESTLINE_KEY
|
||||
elif all(isinstance(node, SC2MOGenMission) for rule_list in progression_tracks.values() for (node, _) in rule_list):
|
||||
progressive_flavor_name = item_names.PROGRESSIVE_MISSION_KEY
|
||||
|
||||
for (track, rule_list) in progression_tracks.items():
|
||||
key_name = item_names._TEMPLATE_PROGRESSIVE_KEY.format(track) if progressive_flavor_name is None else progressive_flavor_name
|
||||
# Determine order in which the rules should unlock
|
||||
ordered_item_rules: List[List[ItemEntryRule]] = []
|
||||
if not any(isinstance(node, SC2MOGenMission) for (node, _) in rule_list):
|
||||
# No rule on this track belongs to a mission, so the rules can be kept in definition order
|
||||
ordered_item_rules = [[item_rule] for (_, item_rule) in rule_list]
|
||||
else:
|
||||
# At least one rule belongs to a mission
|
||||
# Sort rules by the depth of their nodes, ties get the same amount of keys
|
||||
depth_to_rules: Dict[int, List[ItemEntryRule]] = {}
|
||||
for (node, item_rule) in rule_list:
|
||||
depth_to_rules.setdefault(node.get_min_depth(), []).append(item_rule)
|
||||
ordered_item_rules = [depth_to_rules[depth] for depth in sorted(depth_to_rules.keys())]
|
||||
|
||||
# Assign correct progressive keys to each rule
|
||||
for (position, item_rules) in enumerate(ordered_item_rules):
|
||||
for item_rule in item_rules:
|
||||
keys_to_replace = [
|
||||
item_name for (item_name, amount) in item_rule.items_to_check.items()
|
||||
if (item_name.casefold() == GENERIC_PROGRESSIVE_KEY_NAME and amount == track) or (
|
||||
item_name.casefold().startswith(GENERIC_PROGRESSIVE_KEY_NAME) and
|
||||
item_name.split()[-1] == str(track)
|
||||
)
|
||||
]
|
||||
new_items_to_check: Dict[str, int] = {}
|
||||
for (item_name, amount) in item_rule.items_to_check.items():
|
||||
if item_name in keys_to_replace:
|
||||
new_items_to_check[key_name] = position + 1
|
||||
else:
|
||||
new_items_to_check[item_name] = amount
|
||||
item_rule.items_to_check = new_items_to_check
|
||||
mission_order.items_to_lock[key_name] = len(ordered_item_rules)
|
||||
620
worlds/sc2/mission_order/layout_types.py
Normal file
620
worlds/sc2/mission_order/layout_types.py
Normal file
@@ -0,0 +1,620 @@
|
||||
from __future__ import annotations
|
||||
from typing import List, Callable, Set, Tuple, Union, TYPE_CHECKING, Dict, Any
|
||||
import math
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .nodes import SC2MOGenMission
|
||||
|
||||
class LayoutType(ABC):
|
||||
size: int
|
||||
index_functions: List[str] = []
|
||||
"""Names of available functions for mission indices. For list member `"my_fn"`, function should be called `idx_my_fn`."""
|
||||
|
||||
def __init__(self, size: int):
|
||||
self.size = size
|
||||
|
||||
def set_options(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get type-specific options from the provided dict. Should return unused values."""
|
||||
return options
|
||||
|
||||
@abstractmethod
|
||||
def make_slots(self, mission_factory: Callable[[], SC2MOGenMission]) -> List[SC2MOGenMission]:
|
||||
"""Use the provided `Callable` to create a one-dimensional list of mission slots and set up initial settings and connections.
|
||||
|
||||
This should include at least one entrance and exit."""
|
||||
return []
|
||||
|
||||
def final_setup(self, missions: List[SC2MOGenMission]):
|
||||
"""Called after user changes to the layout are applied to make any final checks and changes.
|
||||
|
||||
Implementers should make changes with caution, since it runs after a user's explicit commands are implemented."""
|
||||
return
|
||||
|
||||
def parse_index(self, term: str) -> Union[Set[int], None]:
|
||||
"""From the given term, determine a list of desired target indices. The term is guaranteed to not be "entrances", "exits", or "all".
|
||||
|
||||
If the term cannot be parsed, either raise an exception or return `None`."""
|
||||
return self.parse_index_as_function(term)
|
||||
|
||||
def parse_index_as_function(self, term: str) -> Union[Set[int], None]:
|
||||
"""Helper function to interpret the term as a function call on the layout type, if it is declared in `self.index_functions`.
|
||||
|
||||
Returns the function's return value if `term` is a valid function call, `None` otherwise."""
|
||||
left = term.find('(')
|
||||
right = term.find(')')
|
||||
if left == -1 and right == -1:
|
||||
# Assume no args are desired
|
||||
fn_name = term.strip()
|
||||
fn_args = []
|
||||
elif left == -1 or right == -1:
|
||||
return None
|
||||
else:
|
||||
fn_name = term[:left].strip()
|
||||
fn_args_str = term[left + 1:right]
|
||||
fn_args = [arg.strip() for arg in fn_args_str.split(',')]
|
||||
|
||||
if fn_name in self.index_functions:
|
||||
try:
|
||||
return getattr(self, "idx_" + fn_name)(*fn_args)
|
||||
except:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def get_visual_layout(self) -> List[List[int]]:
|
||||
"""Organize the mission slots into a list of columns from left to right and top to bottom.
|
||||
The list should contain indices into the list created by `make_slots`. Intentionally empty spots should contain -1.
|
||||
|
||||
The resulting 2D list should be rectangular."""
|
||||
pass
|
||||
|
||||
class Column(LayoutType):
|
||||
"""Linear layout. Default entrance is index 0 at the top, default exit is index `size - 1` at the bottom."""
|
||||
|
||||
# 0
|
||||
# 1
|
||||
# 2
|
||||
|
||||
def make_slots(self, mission_factory: Callable[[], SC2MOGenMission]) -> List[SC2MOGenMission]:
|
||||
missions = [mission_factory() for _ in range(self.size)]
|
||||
missions[0].option_entrance = True
|
||||
missions[-1].option_exit = True
|
||||
for i in range(self.size - 1):
|
||||
missions[i].next.append(missions[i + 1])
|
||||
return missions
|
||||
|
||||
def get_visual_layout(self) -> List[List[int]]:
|
||||
return [list(range(self.size))]
|
||||
|
||||
class Grid(LayoutType):
|
||||
"""Rectangular grid. Default entrance is index 0 in the top left, default exit is index `size - 1` in the bottom right."""
|
||||
width: int
|
||||
height: int
|
||||
num_corners_to_remove: int
|
||||
two_start_positions: bool
|
||||
|
||||
index_functions = [
|
||||
"point", "rect"
|
||||
]
|
||||
|
||||
# 0 1 2
|
||||
# 3 4 5
|
||||
# 6 7 8
|
||||
|
||||
def set_options(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
self.two_start_positions = options.pop("two_start_positions", False) and self.size >= 2
|
||||
if self.two_start_positions:
|
||||
self.size += 1
|
||||
width: int = options.pop("width", 0)
|
||||
if width < 1:
|
||||
self.width, self.height, self.num_corners_to_remove = Grid.get_grid_dimensions(self.size)
|
||||
else:
|
||||
self.width = width
|
||||
self.height = math.ceil(self.size / self.width)
|
||||
self.num_corners_to_remove = self.height * width - self.size
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def get_factors(number: int) -> Tuple[int, int]:
|
||||
"""
|
||||
Simple factorization into pairs of numbers (x, y) using a sieve method.
|
||||
Returns the factorization that is most square, i.e. where x + y is minimized.
|
||||
Factor order is such that x <= y.
|
||||
"""
|
||||
assert number > 0
|
||||
for divisor in range(math.floor(math.sqrt(number)), 1, -1):
|
||||
quotient = number // divisor
|
||||
if quotient * divisor == number:
|
||||
return divisor, quotient
|
||||
return 1, number
|
||||
|
||||
@staticmethod
|
||||
def get_grid_dimensions(size: int) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Get the dimensions of a grid mission order from the number of missions, int the format (x, y, error).
|
||||
* Error will always be 0, 1, or 2, so the missions can be removed from the corners that aren't the start or end.
|
||||
* Dimensions are chosen such that x <= y, as buttons in the UI are wider than they are tall.
|
||||
* Dimensions are chosen to be maximally square. That is, x + y + error is minimized.
|
||||
* If multiple options of the same rating are possible, the one with the larger error is chosen,
|
||||
as it will appear more square. Compare 3x11 to 5x7-2 for an example of this.
|
||||
"""
|
||||
dimension_candidates: List[Tuple[int, int, int]] = [(*Grid.get_factors(size + x), x) for x in (2, 1, 0)]
|
||||
best_dimension = min(dimension_candidates, key=sum)
|
||||
return best_dimension
|
||||
|
||||
@staticmethod
|
||||
def manhattan_distance(point1: Tuple[int, int], point2: Tuple[int, int]) -> int:
|
||||
return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1])
|
||||
|
||||
@staticmethod
|
||||
def euclidean_distance_squared(point1: Tuple[int, int], point2: Tuple[int, int]) -> int:
|
||||
return (point1[0] - point2[0]) ** 2 + (point1[1] - point2[1]) ** 2
|
||||
|
||||
@staticmethod
|
||||
def euclidean_distance(point1: Tuple[int, int], point2: Tuple[int, int]) -> float:
|
||||
return math.sqrt(Grid.euclidean_distance_squared(point1, point2))
|
||||
|
||||
def get_grid_coordinates(self, idx: int) -> Tuple[int, int]:
|
||||
return (idx % self.width), (idx // self.width)
|
||||
|
||||
def get_grid_index(self, x: int, y: int) -> int:
|
||||
return y * self.width + x
|
||||
|
||||
def is_valid_coordinates(self, x: int, y: int) -> bool:
|
||||
return (
|
||||
0 <= x < self.width and
|
||||
0 <= y < self.height
|
||||
)
|
||||
|
||||
def make_slots(self, mission_factory: Callable[[], SC2MOGenMission]) -> List[SC2MOGenMission]:
|
||||
missions = [mission_factory() for _ in range(self.width * self.height)]
|
||||
if self.two_start_positions:
|
||||
missions[0].option_empty = True
|
||||
missions[1].option_entrance = True
|
||||
missions[self.get_grid_index(0, 1)].option_entrance = True
|
||||
else:
|
||||
missions[0].option_entrance = True
|
||||
missions[-1].option_exit = True
|
||||
|
||||
for x in range(self.width):
|
||||
left = x - 1
|
||||
right = x + 1
|
||||
for y in range(self.height):
|
||||
up = y - 1
|
||||
down = y + 1
|
||||
idx = self.get_grid_index(x, y)
|
||||
neighbours = [
|
||||
self.get_grid_index(nb_x, nb_y)
|
||||
for (nb_x, nb_y) in [(left, y), (right, y), (x, up), (x, down)]
|
||||
if self.is_valid_coordinates(nb_x, nb_y)
|
||||
]
|
||||
missions[idx].next = [missions[nb] for nb in neighbours]
|
||||
|
||||
# Empty corners
|
||||
top_corners = math.floor(self.num_corners_to_remove / 2)
|
||||
bottom_corners = math.ceil(self.num_corners_to_remove / 2)
|
||||
|
||||
# Bottom left corners
|
||||
y = self.height - 1
|
||||
x = 0
|
||||
leading_x = 0
|
||||
placed = 0
|
||||
while placed < bottom_corners:
|
||||
if x == -1 or y == 0:
|
||||
leading_x += 1
|
||||
x = leading_x
|
||||
y = self.height - 1
|
||||
missions[self.get_grid_index(x, y)].option_empty = True
|
||||
placed += 1
|
||||
x -= 1
|
||||
y -= 1
|
||||
|
||||
# Top right corners
|
||||
y = 0
|
||||
x = self.width - 1
|
||||
leading_x = self.width - 1
|
||||
placed = 0
|
||||
while placed < top_corners:
|
||||
if x == self.width or y == self.height - 1:
|
||||
leading_x -= 1
|
||||
x = leading_x
|
||||
y = 0
|
||||
missions[self.get_grid_index(x, y)].option_empty = True
|
||||
placed += 1
|
||||
x += 1
|
||||
y += 1
|
||||
|
||||
return missions
|
||||
|
||||
def get_visual_layout(self) -> List[List[int]]:
|
||||
columns = [
|
||||
[self.get_grid_index(x, y) for y in range(self.height)]
|
||||
for x in range(self.width)
|
||||
]
|
||||
return columns
|
||||
|
||||
def idx_point(self, x: str, y: str) -> Union[Set[int], None]:
|
||||
try:
|
||||
x = int(x)
|
||||
y = int(y)
|
||||
except:
|
||||
return None
|
||||
if self.is_valid_coordinates(x, y):
|
||||
return {self.get_grid_index(x, y)}
|
||||
return None
|
||||
|
||||
def idx_rect(self, x: str, y: str, width: str, height: str) -> Union[Set[int], None]:
|
||||
try:
|
||||
x = int(x)
|
||||
y = int(y)
|
||||
width = int(width)
|
||||
height = int(height)
|
||||
except:
|
||||
return None
|
||||
indices = {
|
||||
self.get_grid_index(pt_x, pt_y)
|
||||
for pt_y in range(y, y + height)
|
||||
for pt_x in range(x, x + width)
|
||||
if self.is_valid_coordinates(pt_x, pt_y)
|
||||
}
|
||||
return indices
|
||||
|
||||
|
||||
class Canvas(Grid):
|
||||
"""Rectangular grid that determines size and filled slots based on special canvas option."""
|
||||
canvas: List[str]
|
||||
groups: Dict[str, List[int]]
|
||||
jump_distance_orthogonal: int
|
||||
jump_distance_diagonal: int
|
||||
|
||||
jumps_orthogonal = [(-1, 0), (0, 1), (1, 0), (0, -1)]
|
||||
jumps_diagonal = [(-1, -1), (-1, 1), (1, 1), (1, -1)]
|
||||
|
||||
index_functions = Grid.index_functions + ["group"]
|
||||
|
||||
def set_options(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
self.width = options.pop("width") # Should be guaranteed by the option parser
|
||||
self.height = math.ceil(self.size / self.width)
|
||||
self.num_corners_to_remove = 0
|
||||
self.two_start_positions = False
|
||||
self.jump_distance_orthogonal = max(options.pop("jump_distance_orthogonal", 1), 1)
|
||||
self.jump_distance_diagonal = max(options.pop("jump_distance_diagonal", 1), 0)
|
||||
|
||||
if "canvas" not in options:
|
||||
raise KeyError("Canvas layout is missing required canvas option. Either create it or change type to Grid.")
|
||||
self.canvas = options.pop("canvas")
|
||||
# Pad short lines with spaces
|
||||
longest_line = max(len(line) for line in self.canvas)
|
||||
for idx in range(len(self.canvas)):
|
||||
padding = ' ' * (longest_line - len(self.canvas[idx]))
|
||||
self.canvas[idx] += padding
|
||||
|
||||
self.groups = {}
|
||||
for (line_idx, line) in enumerate(self.canvas):
|
||||
for (char_idx, char) in enumerate(line):
|
||||
self.groups.setdefault(char, []).append(self.get_grid_index(char_idx, line_idx))
|
||||
|
||||
return options
|
||||
|
||||
def make_slots(self, mission_factory: Callable[[], SC2MOGenMission]) -> List[SC2MOGenMission]:
|
||||
missions = super().make_slots(mission_factory)
|
||||
missions[0].option_entrance = False
|
||||
missions[-1].option_exit = False
|
||||
|
||||
# Canvas spaces become empty slots
|
||||
for idx in self.groups.get(" ", []):
|
||||
missions[idx].option_empty = True
|
||||
|
||||
# Raycast into jump directions to find nearest empty space
|
||||
def jump(point: Tuple[int, int], direction: Tuple[int, int], distance: int) -> Tuple[int, int]:
|
||||
return (
|
||||
point[0] + direction[0] * distance,
|
||||
point[1] + direction[1] * distance
|
||||
)
|
||||
|
||||
def raycast(point: Tuple[int, int], direction: Tuple[int, int], max_distance: int) -> Union[Tuple[int, SC2MOGenMission], None]:
|
||||
for distance in range(1, max_distance + 1):
|
||||
target = jump(point, direction, distance)
|
||||
if self.is_valid_coordinates(*target):
|
||||
target_mission = missions[self.get_grid_index(*target)]
|
||||
if not target_mission.option_empty:
|
||||
return (distance, target_mission)
|
||||
else:
|
||||
# Out of bounds
|
||||
return None
|
||||
return None
|
||||
|
||||
for (idx, mission) in enumerate(missions):
|
||||
if mission.option_empty:
|
||||
continue
|
||||
point = self.get_grid_coordinates(idx)
|
||||
if self.jump_distance_orthogonal > 1:
|
||||
for direction in Canvas.jumps_orthogonal:
|
||||
target = raycast(point, direction, self.jump_distance_orthogonal)
|
||||
if target is not None:
|
||||
(distance, target_mission) = target
|
||||
if distance > 1:
|
||||
# Distance 1 orthogonal jumps already come from the base grid
|
||||
mission.next.append(target[1])
|
||||
if self.jump_distance_diagonal > 0:
|
||||
for direction in Canvas.jumps_diagonal:
|
||||
target = raycast(point, direction, self.jump_distance_diagonal)
|
||||
if target is not None:
|
||||
(distance, target_mission) = target
|
||||
if distance == 1:
|
||||
# Keep distance 1 diagonal slots only if the orthogonal neighbours are empty
|
||||
x_neighbour = jump(point, (direction[0], 0), 1)
|
||||
y_neighbour = jump(point, (0, direction[1]), 1)
|
||||
if (
|
||||
missions[self.get_grid_index(*x_neighbour)].option_empty and
|
||||
missions[self.get_grid_index(*y_neighbour)].option_empty
|
||||
):
|
||||
mission.next.append(target_mission)
|
||||
else:
|
||||
mission.next.append(target_mission)
|
||||
|
||||
return missions
|
||||
|
||||
def final_setup(self, missions: List[SC2MOGenMission]):
|
||||
# Pick missions near the original start and end to set as default entrance/exit
|
||||
# if the user didn't set one themselves
|
||||
def distance_lambda(point: Tuple[int, int]) -> Callable[[Tuple[int, SC2MOGenMission]], int]:
|
||||
return lambda idx_mission: Grid.euclidean_distance_squared(self.get_grid_coordinates(idx_mission[0]), point)
|
||||
|
||||
if not any(mission.option_entrance for mission in missions):
|
||||
top_left = self.get_grid_coordinates(0)
|
||||
closest_to_top_left = sorted(
|
||||
((idx, mission) for (idx, mission) in enumerate(missions) if not mission.option_empty),
|
||||
key = distance_lambda(top_left)
|
||||
)
|
||||
closest_to_top_left[0][1].option_entrance = True
|
||||
|
||||
if not any(mission.option_exit for mission in missions):
|
||||
bottom_right = self.get_grid_coordinates(len(missions) - 1)
|
||||
closest_to_bottom_right = sorted(
|
||||
((idx, mission) for (idx, mission) in enumerate(missions) if not mission.option_empty),
|
||||
key = distance_lambda(bottom_right)
|
||||
)
|
||||
closest_to_bottom_right[0][1].option_exit = True
|
||||
|
||||
def idx_group(self, group: str) -> Union[Set[int], None]:
|
||||
if group not in self.groups:
|
||||
return None
|
||||
return set(self.groups[group])
|
||||
|
||||
|
||||
class Hopscotch(LayoutType):
|
||||
"""Alternating between one and two available missions.
|
||||
Default entrance is index 0 in the top left, default exit is index `size - 1` in the bottom right."""
|
||||
width: int
|
||||
spacer: int
|
||||
two_start_positions: bool
|
||||
|
||||
index_functions = [
|
||||
"top", "bottom", "middle", "corner"
|
||||
]
|
||||
|
||||
# 0 2
|
||||
# 1 3 5
|
||||
# 4 6
|
||||
# 7
|
||||
|
||||
def set_options(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
self.two_start_positions = options.pop("two_start_positions", False) and self.size >= 2
|
||||
if self.two_start_positions:
|
||||
self.size += 1
|
||||
width: int = options.pop("width", 7)
|
||||
self.width = max(width, 4)
|
||||
spacer: int = options.pop("spacer", 2)
|
||||
self.spacer = max(spacer, 1)
|
||||
return options
|
||||
|
||||
def make_slots(self, mission_factory: Callable[[], SC2MOGenMission]) -> List[SC2MOGenMission]:
|
||||
slots = [mission_factory() for _ in range(self.size)]
|
||||
if self.two_start_positions:
|
||||
slots[0].option_empty = True
|
||||
slots[1].option_entrance = True
|
||||
slots[2].option_entrance = True
|
||||
else:
|
||||
slots[0].option_entrance = True
|
||||
slots[-1].option_exit = True
|
||||
|
||||
cycle = 0
|
||||
for idx in range(self.size):
|
||||
if cycle == 0:
|
||||
indices = [idx + 1, idx + 2]
|
||||
cycle = 2
|
||||
elif cycle == 1:
|
||||
indices = [idx + 1]
|
||||
cycle -= 1
|
||||
else:
|
||||
indices = [idx + 2]
|
||||
cycle -= 1
|
||||
for next_idx in indices:
|
||||
if next_idx < self.size:
|
||||
slots[idx].next.append(slots[next_idx])
|
||||
|
||||
return slots
|
||||
|
||||
@staticmethod
|
||||
def space_at_column(idx: int) -> List[int]:
|
||||
# -1 0 1 2 3 4 5
|
||||
amount = idx - 1
|
||||
if amount > 0:
|
||||
return [-1 for _ in range(amount)]
|
||||
else:
|
||||
return []
|
||||
|
||||
def get_visual_layout(self) -> List[List[int]]:
|
||||
# size offset by 1 to account for first column of two slots
|
||||
cols: List[List[int]] = []
|
||||
col: List[int] = []
|
||||
col_size = 1
|
||||
for idx in range(self.size):
|
||||
if col_size == 3:
|
||||
col_size = 1
|
||||
cols.append(col)
|
||||
col = [idx]
|
||||
else:
|
||||
col_size += 1
|
||||
col.append(idx)
|
||||
if len(col) > 0:
|
||||
cols.append(col)
|
||||
|
||||
final_cols: List[List[int]] = [Hopscotch.space_at_column(idx) for idx in range(min(len(cols), self.width))]
|
||||
for (col_idx, col) in enumerate(cols):
|
||||
if col_idx >= self.width:
|
||||
final_cols[col_idx % self.width].extend([-1 for _ in range(self.spacer)])
|
||||
final_cols[col_idx % self.width].extend(col)
|
||||
|
||||
fill_to_longest(final_cols)
|
||||
|
||||
return final_cols
|
||||
|
||||
def idx_bottom(self) -> Set[int]:
|
||||
corners = math.ceil(self.size / 3)
|
||||
indices = [num * 3 + 1 for num in range(corners)]
|
||||
return {
|
||||
idx for idx in indices if idx < self.size
|
||||
}
|
||||
|
||||
def idx_top(self) -> Set[int]:
|
||||
corners = math.ceil(self.size / 3)
|
||||
indices = [num * 3 + 2 for num in range(corners)]
|
||||
return {
|
||||
idx for idx in indices if idx < self.size
|
||||
}
|
||||
|
||||
def idx_middle(self) -> Set[int]:
|
||||
corners = math.ceil(self.size / 3)
|
||||
indices = [num * 3 for num in range(corners)]
|
||||
return {
|
||||
idx for idx in indices if idx < self.size
|
||||
}
|
||||
|
||||
def idx_corner(self, number: str) -> Union[Set[int], None]:
|
||||
try:
|
||||
number = int(number)
|
||||
except:
|
||||
return None
|
||||
corners = math.ceil(self.size / 3)
|
||||
if number >= corners:
|
||||
return None
|
||||
indices = [number * 3 + n for n in range(3)]
|
||||
return {
|
||||
idx for idx in indices if idx < self.size
|
||||
}
|
||||
|
||||
|
||||
class Gauntlet(LayoutType):
|
||||
"""Long, linear layout. Goes horizontally and wraps around.
|
||||
Default entrance is index 0 in the top left, default exit is index `size - 1` in the bottom right."""
|
||||
width: int
|
||||
|
||||
# 0 1 2 3
|
||||
#
|
||||
# 4 5 6 7
|
||||
|
||||
def set_options(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
width: int = options.pop("width", 7)
|
||||
self.width = min(max(width, 4), self.size)
|
||||
return options
|
||||
|
||||
def make_slots(self, mission_factory: Callable[[], SC2MOGenMission]) -> List[SC2MOGenMission]:
|
||||
missions = [mission_factory() for _ in range(self.size)]
|
||||
missions[0].option_entrance = True
|
||||
missions[-1].option_exit = True
|
||||
for i in range(self.size - 1):
|
||||
missions[i].next.append(missions[i + 1])
|
||||
return missions
|
||||
|
||||
def get_visual_layout(self) -> List[List[int]]:
|
||||
columns = [[] for _ in range(self.width)]
|
||||
for idx in range(self.size):
|
||||
if idx >= self.width:
|
||||
columns[idx % self.width].append(-1)
|
||||
columns[idx % self.width].append(idx)
|
||||
|
||||
fill_to_longest(columns)
|
||||
|
||||
return columns
|
||||
|
||||
class Blitz(LayoutType):
|
||||
"""Rows of missions, one mission per row required.
|
||||
Default entrances are every mission in the top row, default exit is a central mission in the bottom row."""
|
||||
width: int
|
||||
|
||||
index_functions = [
|
||||
"row"
|
||||
]
|
||||
|
||||
# 0 1 2 3
|
||||
# 4 5 6 7
|
||||
|
||||
def set_options(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
width = options.pop("width", 0)
|
||||
if width < 1:
|
||||
min_width, max_width = 2, 5
|
||||
mission_divisor = 5
|
||||
self.width = min(max(self.size // mission_divisor, min_width), max_width)
|
||||
else:
|
||||
self.width = min(self.size, width)
|
||||
return options
|
||||
|
||||
def make_slots(self, mission_factory: Callable[[], SC2MOGenMission]) -> List[SC2MOGenMission]:
|
||||
slots = [mission_factory() for _ in range(self.size)]
|
||||
for idx in range(self.width):
|
||||
slots[idx].option_entrance = True
|
||||
|
||||
# TODO: this is copied from the original mission order and works, but I'm not sure on the intent
|
||||
# middle_column = self.width // 2
|
||||
# if self.size % self.width > middle_column:
|
||||
# final_row = self.width * (self.size // self.width)
|
||||
# final_mission = final_row + middle_column
|
||||
# else:
|
||||
# final_mission = self.size - 1
|
||||
# slots[final_mission].option_exit = True
|
||||
|
||||
rows = self.size // self.width
|
||||
for row in range(rows):
|
||||
for top in range(self.width):
|
||||
idx = row * self.width + top
|
||||
for bot in range(self.width):
|
||||
other = (row + 1) * self.width + bot
|
||||
if other < self.size:
|
||||
slots[idx].next.append(slots[other])
|
||||
if row == rows-1:
|
||||
slots[idx].option_exit = True
|
||||
|
||||
return slots
|
||||
|
||||
def get_visual_layout(self) -> List[List[int]]:
|
||||
columns = [[] for _ in range(self.width)]
|
||||
for idx in range(self.size):
|
||||
columns[idx % self.width].append(idx)
|
||||
|
||||
fill_to_longest(columns)
|
||||
|
||||
return columns
|
||||
|
||||
def idx_row(self, row: str) -> Union[Set[int], None]:
|
||||
try:
|
||||
row = int(row)
|
||||
except:
|
||||
return None
|
||||
rows = math.ceil(self.size / self.width)
|
||||
if row >= rows:
|
||||
return None
|
||||
indices = [row * self.width + col for col in range(self.width)]
|
||||
return {
|
||||
idx for idx in indices if idx < self.size
|
||||
}
|
||||
|
||||
def fill_to_longest(columns: List[List[int]]):
|
||||
longest = max(len(col) for col in columns)
|
||||
for idx in range(len(columns)):
|
||||
length = len(columns[idx])
|
||||
if length < longest:
|
||||
columns[idx].extend([-1 for _ in range(longest - length)])
|
||||
251
worlds/sc2/mission_order/mission_pools.py
Normal file
251
worlds/sc2/mission_order/mission_pools.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from enum import IntEnum
|
||||
from typing import TYPE_CHECKING, Dict, Set, List, Iterable
|
||||
|
||||
from Options import OptionError
|
||||
from ..mission_tables import SC2Mission, lookup_id_to_mission, MissionFlag, SC2Campaign
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .nodes import SC2MOGenMission
|
||||
|
||||
class Difficulty(IntEnum):
|
||||
RELATIVE = 0
|
||||
STARTER = 1
|
||||
EASY = 2
|
||||
MEDIUM = 3
|
||||
HARD = 4
|
||||
VERY_HARD = 5
|
||||
|
||||
# TODO figure out an organic way to get these
|
||||
DEFAULT_DIFFICULTY_THRESHOLDS = {
|
||||
Difficulty.STARTER: 0,
|
||||
Difficulty.EASY: 10,
|
||||
Difficulty.MEDIUM: 35,
|
||||
Difficulty.HARD: 65,
|
||||
Difficulty.VERY_HARD: 90,
|
||||
Difficulty.VERY_HARD + 1: 100
|
||||
}
|
||||
|
||||
STANDARD_DIFFICULTY_FILL_ORDER = (
|
||||
Difficulty.VERY_HARD,
|
||||
Difficulty.STARTER,
|
||||
Difficulty.HARD,
|
||||
Difficulty.EASY,
|
||||
Difficulty.MEDIUM,
|
||||
)
|
||||
"""Fill mission slots outer->inner difficulties,
|
||||
so if multiple pools get exhausted, they will tend to overflow towards the middle."""
|
||||
|
||||
def modified_difficulty_thresholds(min_difficulty: Difficulty, max_difficulty: Difficulty) -> Dict[int, Difficulty]:
|
||||
if min_difficulty == Difficulty.RELATIVE:
|
||||
min_difficulty = Difficulty.STARTER
|
||||
if max_difficulty == Difficulty.RELATIVE:
|
||||
max_difficulty = Difficulty.VERY_HARD
|
||||
thresholds: Dict[int, Difficulty] = {}
|
||||
min_thresh = DEFAULT_DIFFICULTY_THRESHOLDS[min_difficulty]
|
||||
total_thresh = DEFAULT_DIFFICULTY_THRESHOLDS[max_difficulty + 1] - min_thresh
|
||||
for difficulty in range(min_difficulty, max_difficulty + 1):
|
||||
threshold = DEFAULT_DIFFICULTY_THRESHOLDS[difficulty] - min_thresh
|
||||
threshold *= 100 // total_thresh
|
||||
thresholds[threshold] = Difficulty(difficulty)
|
||||
return thresholds
|
||||
|
||||
class SC2MOGenMissionPools:
|
||||
"""
|
||||
Manages available and used missions for a mission order.
|
||||
"""
|
||||
master_list: Set[int]
|
||||
difficulty_pools: Dict[Difficulty, Set[int]]
|
||||
_used_flags: Dict[MissionFlag, int]
|
||||
_used_missions: List[SC2Mission]
|
||||
_updated_difficulties: Dict[int, Difficulty]
|
||||
_flag_ratios: Dict[MissionFlag, float]
|
||||
_flag_weights: Dict[MissionFlag, int]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.master_list = {mission.id for mission in SC2Mission}
|
||||
self.difficulty_pools = {
|
||||
diff: {mission.id for mission in SC2Mission if mission.pool + 1 == diff}
|
||||
for diff in Difficulty if diff != Difficulty.RELATIVE
|
||||
}
|
||||
self._used_flags = {}
|
||||
self._used_missions = []
|
||||
self._updated_difficulties = {}
|
||||
self._flag_ratios = {}
|
||||
self._flag_weights = {}
|
||||
|
||||
def set_exclusions(self, excluded: Iterable[SC2Mission], unexcluded: Iterable[SC2Mission]) -> None:
|
||||
"""Prevents all the missions that appear in the `excluded` list, but not in the `unexcluded` list,
|
||||
from appearing in the mission order."""
|
||||
total_exclusions = [mission.id for mission in excluded if mission not in unexcluded]
|
||||
self.master_list.difference_update(total_exclusions)
|
||||
|
||||
def get_allowed_mission_count(self) -> int:
|
||||
return len(self.master_list)
|
||||
|
||||
def count_allowed_missions(self, campaign: SC2Campaign) -> int:
|
||||
allowed_missions = [
|
||||
mission_id
|
||||
for mission_id in self.master_list
|
||||
if lookup_id_to_mission[mission_id].campaign == campaign
|
||||
]
|
||||
return len(allowed_missions)
|
||||
|
||||
def move_mission(self, mission: SC2Mission, old_diff: Difficulty, new_diff: Difficulty) -> None:
|
||||
"""Changes the difficulty of the given `mission`. Does nothing if the mission is not allowed to appear
|
||||
or if it isn't set to the `old_diff` difficulty."""
|
||||
if mission.id in self.master_list and mission.id in self.difficulty_pools[old_diff]:
|
||||
self.difficulty_pools[old_diff].remove(mission.id)
|
||||
self.difficulty_pools[new_diff].add(mission.id)
|
||||
self._updated_difficulties[mission.id] = new_diff
|
||||
|
||||
def get_modified_mission_difficulty(self, mission: SC2Mission) -> Difficulty:
|
||||
if mission.id in self._updated_difficulties:
|
||||
return self._updated_difficulties[mission.id]
|
||||
return Difficulty(mission.pool + 1)
|
||||
|
||||
def get_pool_size(self, diff: Difficulty) -> int:
|
||||
"""Returns the amount of missions of the given difficulty that are allowed to appear."""
|
||||
return len(self.difficulty_pools[diff])
|
||||
|
||||
def get_used_flags(self) -> Dict[MissionFlag, int]:
|
||||
"""Returns a dictionary of all used flags and their appearance count within the mission order.
|
||||
Flags that don't appear in the mission order also don't appear in this dictionary."""
|
||||
return self._used_flags
|
||||
|
||||
def get_used_missions(self) -> List[SC2Mission]:
|
||||
"""Returns a set of all missions used in the mission order."""
|
||||
return self._used_missions
|
||||
|
||||
def set_flag_balances(self, flag_ratios: Dict[MissionFlag, int], flag_weights: Dict[MissionFlag, int]):
|
||||
# Ensure the ratios are percentages
|
||||
ratio_sum = sum(ratio for ratio in flag_ratios.values())
|
||||
self._flag_ratios = {flag: ratio / ratio_sum for flag, ratio in flag_ratios.items()}
|
||||
self._flag_weights = flag_weights
|
||||
|
||||
def pick_balanced_mission(self, world: World, pool: List[int]) -> int:
|
||||
"""Applies ratio-based and weight-based balancing to pick a preferred mission from a given mission pool."""
|
||||
# Currently only used for race balancing
|
||||
# Untested for flags that may overlap or not be present at all, but should at least generate
|
||||
balanced_pool = pool
|
||||
if len(self._flag_ratios) > 0:
|
||||
relevant_used_flag_count = max(sum(self._used_flags.get(flag, 0) for flag in self._flag_ratios), 1)
|
||||
current_ratios = {
|
||||
flag: self._used_flags.get(flag, 0) / relevant_used_flag_count
|
||||
for flag in self._flag_ratios
|
||||
}
|
||||
# Desirability of missions is the difference between target and current ratios for relevant flags
|
||||
flag_scores = {
|
||||
flag: self._flag_ratios[flag] - current_ratios[flag]
|
||||
for flag in self._flag_ratios
|
||||
}
|
||||
mission_scores = [
|
||||
sum(
|
||||
flag_scores[flag] for flag in self._flag_ratios
|
||||
if flag in lookup_id_to_mission[mission].flags
|
||||
)
|
||||
for mission in balanced_pool
|
||||
]
|
||||
# Only keep the missions that create the best balance
|
||||
best_score = max(mission_scores)
|
||||
balanced_pool = [mission for idx, mission in enumerate(balanced_pool) if mission_scores[idx] == best_score]
|
||||
|
||||
balanced_weights = [1.0 for _ in balanced_pool]
|
||||
if len(self._flag_weights) > 0:
|
||||
relevant_used_flag_count = max(sum(self._used_flags.get(flag, 0) for flag in self._flag_weights), 1)
|
||||
# Higher usage rate of relevant flags means lower desirability
|
||||
flag_scores = {
|
||||
flag: (relevant_used_flag_count - self._used_flags.get(flag, 0)) * self._flag_weights[flag]
|
||||
for flag in self._flag_weights
|
||||
}
|
||||
# Mission scores are averaged across the mission's flags,
|
||||
# else flags that aren't always present will inflate weights
|
||||
mission_scores = [
|
||||
sum(
|
||||
flag_scores[flag] for flag in self._flag_weights
|
||||
if flag in lookup_id_to_mission[mission].flags
|
||||
) / sum(flag in lookup_id_to_mission[mission].flags for flag in self._flag_weights)
|
||||
for mission in balanced_pool
|
||||
]
|
||||
balanced_weights = mission_scores
|
||||
|
||||
if sum(balanced_weights) == 0.0:
|
||||
balanced_weights = [1.0 for _ in balanced_weights]
|
||||
return world.random.choices(balanced_pool, balanced_weights, k=1)[0]
|
||||
|
||||
def pull_specific_mission(self, mission: SC2Mission) -> None:
|
||||
"""Marks the given mission as present in the mission order."""
|
||||
# Remove the mission from the master list and whichever difficulty pool it is in
|
||||
if mission.id in self.master_list:
|
||||
self.master_list.remove(mission.id)
|
||||
for diff in self.difficulty_pools:
|
||||
if mission.id in self.difficulty_pools[diff]:
|
||||
self.difficulty_pools[diff].remove(mission.id)
|
||||
break
|
||||
self._add_mission_stats(mission)
|
||||
|
||||
def _add_mission_stats(self, mission: SC2Mission) -> None:
|
||||
# Update used flag counts & missions
|
||||
# Done weirdly for Python <= 3.10 compatibility
|
||||
flag: MissionFlag
|
||||
for flag in iter(MissionFlag): # type: ignore
|
||||
if flag & mission.flags == flag:
|
||||
self._used_flags.setdefault(flag, 0)
|
||||
self._used_flags[flag] += 1
|
||||
self._used_missions.append(mission)
|
||||
|
||||
def pull_random_mission(self, world: World, slot: 'SC2MOGenMission', *, prefer_close_difficulty: bool = False) -> SC2Mission:
|
||||
"""Picks a random mission from the mission pool of the given slot and marks it as present in the mission order.
|
||||
|
||||
With `prefer_close_difficulty = True` the mission is picked to be as close to the slot's desired difficulty as possible."""
|
||||
pool = slot.option_mission_pool.intersection(self.master_list)
|
||||
|
||||
difficulty_pools: Dict[int, List[int]] = {
|
||||
diff: sorted(pool.intersection(self.difficulty_pools[diff]))
|
||||
for diff in Difficulty if diff != Difficulty.RELATIVE
|
||||
}
|
||||
|
||||
if len(pool) == 0:
|
||||
raise OptionError(f"No available mission to be picked for slot {slot.get_address_to_node()}.")
|
||||
|
||||
desired_difficulty = slot.option_difficulty
|
||||
if prefer_close_difficulty:
|
||||
# Iteratively look up and down around the slot's desired difficulty
|
||||
# Either a difficulty with valid missions is found, or an error is raised
|
||||
difficulty_offset = 0
|
||||
final_pool = difficulty_pools[desired_difficulty]
|
||||
while len(final_pool) == 0:
|
||||
higher_diff = min(desired_difficulty + difficulty_offset + 1, Difficulty.VERY_HARD)
|
||||
final_pool = difficulty_pools[higher_diff]
|
||||
if len(final_pool) > 0:
|
||||
break
|
||||
lower_diff = max(desired_difficulty - difficulty_offset, Difficulty.STARTER)
|
||||
final_pool = difficulty_pools[lower_diff]
|
||||
if len(final_pool) > 0:
|
||||
break
|
||||
if lower_diff == Difficulty.STARTER and higher_diff == Difficulty.VERY_HARD:
|
||||
raise IndexError()
|
||||
difficulty_offset += 1
|
||||
|
||||
else:
|
||||
# Consider missions from all lower difficulties as well the desired difficulty
|
||||
# Only take from higher difficulties if no lower difficulty is possible
|
||||
final_pool = [
|
||||
mission
|
||||
for difficulty in range(Difficulty.STARTER, desired_difficulty + 1)
|
||||
for mission in difficulty_pools[difficulty]
|
||||
]
|
||||
difficulty_offset = 1
|
||||
while len(final_pool) == 0:
|
||||
higher_difficulty = desired_difficulty + difficulty_offset
|
||||
if higher_difficulty > Difficulty.VERY_HARD:
|
||||
raise IndexError()
|
||||
final_pool = difficulty_pools[higher_difficulty]
|
||||
difficulty_offset += 1
|
||||
|
||||
# Remove the mission from the master list
|
||||
mission = lookup_id_to_mission[self.pick_balanced_mission(world, final_pool)]
|
||||
self.master_list.remove(mission.id)
|
||||
self.difficulty_pools[self.get_modified_mission_difficulty(mission)].remove(mission.id)
|
||||
self._add_mission_stats(mission)
|
||||
return mission
|
||||
606
worlds/sc2/mission_order/nodes.py
Normal file
606
worlds/sc2/mission_order/nodes.py
Normal file
@@ -0,0 +1,606 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
472
worlds/sc2/mission_order/options.py
Normal file
472
worlds/sc2/mission_order/options.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
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
|
||||
|
||||
164
worlds/sc2/mission_order/presets_scripted.py
Normal file
164
worlds/sc2/mission_order/presets_scripted.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from typing import Dict, Any, List
|
||||
import copy
|
||||
|
||||
def _required_option(option: str, options: Dict[str, Any]) -> Any:
|
||||
"""Returns the option value, or raises an error if the option is not present."""
|
||||
if option not in options:
|
||||
raise KeyError(f"Campaign preset is missing required option \"{option}\".")
|
||||
return options.pop(option)
|
||||
|
||||
def _validate_option(option: str, options: Dict[str, str], default: str, valid_values: List[str]) -> str:
|
||||
"""Returns the option value if it is present and valid, the default if it is not present, or raises an error if it is present but not valid."""
|
||||
result = options.pop(option, default)
|
||||
if result not in valid_values:
|
||||
raise ValueError(f"Preset option \"{option}\" received unknown value \"{result}\".")
|
||||
return result
|
||||
|
||||
def make_golden_path(options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
chain_name_options = ['Mar Sara', 'Agria', 'Redstone', 'Meinhoff', 'Haven', 'Tarsonis', 'Valhalla', 'Char',
|
||||
'Umoja', 'Kaldir', 'Zerus', 'Skygeirr Station', 'Dominion Space', 'Korhal',
|
||||
'Aiur', 'Glacius', 'Shakuras', 'Ulnar', 'Slayn',
|
||||
'Antiga', 'Braxis', 'Chau Sara', 'Moria', 'Tyrador', 'Xil', 'Zhakul',
|
||||
'Azeroth', 'Crouton', 'Draenor', 'Sanctuary']
|
||||
|
||||
size = max(_required_option("size", options), 4)
|
||||
keys_option_values = ["none", "layouts", "missions", "progressive_layouts", "progressive_missions", "progressive_per_layout"]
|
||||
keys_option = _validate_option("keys", options, "none", keys_option_values)
|
||||
min_chains = 2
|
||||
max_chains = 6
|
||||
two_start_positions = options.pop("two_start_positions", False)
|
||||
# Compensating for empty mission at start
|
||||
if two_start_positions:
|
||||
size += 1
|
||||
|
||||
class Campaign:
|
||||
def __init__(self, missions_remaining: int):
|
||||
self.chain_lengths = [1]
|
||||
self.chain_padding = [0]
|
||||
self.required_missions = [0]
|
||||
self.padding = 0
|
||||
self.missions_remaining = missions_remaining
|
||||
self.mission_counter = 1
|
||||
|
||||
def add_mission(self, chain: int, required_missions: int = 0, *, is_final: bool = False):
|
||||
if self.missions_remaining == 0 and not is_final:
|
||||
return
|
||||
|
||||
self.mission_counter += 1
|
||||
self.chain_lengths[chain] += 1
|
||||
self.missions_remaining -= 1
|
||||
|
||||
if chain == 0:
|
||||
self.padding += 1
|
||||
self.required_missions.append(required_missions)
|
||||
|
||||
def add_chain(self):
|
||||
self.chain_lengths.append(0)
|
||||
self.chain_padding.append(self.padding)
|
||||
|
||||
campaign = Campaign(size - 2)
|
||||
current_required_missions = 0
|
||||
main_chain_length = 0
|
||||
while campaign.missions_remaining > 0:
|
||||
main_chain_length += 1
|
||||
if main_chain_length % 2 == 1: # Adding branches
|
||||
chains_to_make = 0 if len(campaign.chain_lengths) >= max_chains else min_chains if main_chain_length == 1 else 1
|
||||
for _ in range(chains_to_make):
|
||||
campaign.add_chain()
|
||||
# Updating branches
|
||||
for side_chain in range(len(campaign.chain_lengths) - 1, 0, -1):
|
||||
campaign.add_mission(side_chain)
|
||||
# Adding main path mission
|
||||
current_required_missions = (campaign.mission_counter * 3) // 4
|
||||
if two_start_positions:
|
||||
# Compensating for skipped mission at start
|
||||
current_required_missions -= 1
|
||||
campaign.add_mission(0, current_required_missions)
|
||||
campaign.add_mission(0, current_required_missions, is_final = True)
|
||||
|
||||
# Create mission order preset out of campaign
|
||||
layout_base = {
|
||||
"type": "column",
|
||||
"display_name": chain_name_options,
|
||||
"unique_name": True,
|
||||
"missions": [],
|
||||
}
|
||||
# Optionally add key requirement to layouts
|
||||
if keys_option == "layouts":
|
||||
layout_base["entry_rules"] = [{ "items": { "Key": 1 }}]
|
||||
elif keys_option == "progressive_layouts":
|
||||
layout_base["entry_rules"] = [{ "items": { "Progressive Key": 0 }}]
|
||||
preset = {
|
||||
str(chain): copy.deepcopy(layout_base) for chain in range(len(campaign.chain_lengths))
|
||||
}
|
||||
preset["0"]["exit"] = True
|
||||
if not two_start_positions:
|
||||
preset["0"].pop("entry_rules", [])
|
||||
for chain in range(len(campaign.chain_lengths)):
|
||||
length = campaign.chain_lengths[chain]
|
||||
padding = campaign.chain_padding[chain]
|
||||
preset[str(chain)]["size"] = padding + length
|
||||
# Add padding to chain
|
||||
if padding > 0:
|
||||
preset[str(chain)]["missions"].append({
|
||||
"index": [pad for pad in range(padding)],
|
||||
"empty": True
|
||||
})
|
||||
|
||||
if chain == 0:
|
||||
if two_start_positions:
|
||||
preset["0"]["missions"].append({
|
||||
"index": 0,
|
||||
"empty": True
|
||||
})
|
||||
# Main path gets number requirements
|
||||
for mission in range(1, len(campaign.required_missions)):
|
||||
preset["0"]["missions"].append({
|
||||
"index": mission,
|
||||
"entry_rules": [{
|
||||
"scope": "../..",
|
||||
"amount": campaign.required_missions[mission]
|
||||
}]
|
||||
})
|
||||
# Optionally add key requirements except to the starter mission
|
||||
if keys_option == "missions":
|
||||
for slot in preset["0"]["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"].append({ "items": { "Key": 1 }})
|
||||
elif keys_option == "progressive_missions":
|
||||
for slot in preset["0"]["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"].append({ "items": { "Progressive Key": 1 }})
|
||||
# No main chain keys for progressive_per_layout keys
|
||||
else:
|
||||
# Other paths get main path requirements
|
||||
if two_start_positions and chain < 3:
|
||||
preset[str(chain)].pop("entry_rules", [])
|
||||
for mission in range(length):
|
||||
target = padding + mission
|
||||
if two_start_positions and mission == 0 and chain < 3:
|
||||
preset[str(chain)]["missions"].append({
|
||||
"index": target,
|
||||
"entrance": True
|
||||
})
|
||||
else:
|
||||
preset[str(chain)]["missions"].append({
|
||||
"index": target,
|
||||
"entry_rules": [{
|
||||
"scope": f"../../0/{target}"
|
||||
}]
|
||||
})
|
||||
# Optionally add key requirements
|
||||
if keys_option == "missions":
|
||||
for slot in preset[str(chain)]["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"].append({ "items": { "Key": 1 }})
|
||||
elif keys_option == "progressive_missions":
|
||||
for slot in preset[str(chain)]["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"].append({ "items": { "Progressive Key": 1 }})
|
||||
elif keys_option == "progressive_per_layout":
|
||||
for slot in preset[str(chain)]["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"].append({ "items": { "Progressive Key": 0 }})
|
||||
return preset
|
||||
916
worlds/sc2/mission_order/presets_static.py
Normal file
916
worlds/sc2/mission_order/presets_static.py
Normal file
@@ -0,0 +1,916 @@
|
||||
from typing import Dict, Any, Callable, List, Tuple
|
||||
import copy
|
||||
|
||||
from ..mission_groups import MissionGroupNames
|
||||
from ..mission_tables import SC2Mission, SC2Campaign
|
||||
|
||||
preset_mini_wol_with_prophecy = {
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.WOL_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Mar Sara": {
|
||||
"size": 1
|
||||
},
|
||||
"Colonist": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Artifact": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 1, "entry_rules": [{ "scope": "../..", "amount": 4 }, { "items": { "Key": 1 }}] },
|
||||
{ "index": 2, "entry_rules": [{ "scope": "../..", "amount": 8 }, { "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Prophecy": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Artifact/1" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"mission_pool": [
|
||||
MissionGroupNames.PROPHECY_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Covert": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "scope": "..", "amount": 2 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Rebellion": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "scope": "..", "amount": 3 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Char": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Artifact" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "next": [2] },
|
||||
{ "index": 1, "entrance": True }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_mini_wol = copy.deepcopy(preset_mini_wol_with_prophecy)
|
||||
preset_mini_prophecy = { "Prophecy": preset_mini_wol.pop("Prophecy") }
|
||||
preset_mini_prophecy["Prophecy"].pop("entry_rules")
|
||||
preset_mini_prophecy["Prophecy"]["type"] = "gauntlet"
|
||||
preset_mini_prophecy["Prophecy"]["display_name"] = ""
|
||||
preset_mini_prophecy["Prophecy"]["missions"].append({ "index": "entrances", "entry_rules": [] })
|
||||
|
||||
preset_mini_hots = {
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.HOTS_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Umoja": {
|
||||
"size": 1,
|
||||
},
|
||||
"Kaldir": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Umoja" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Char": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Umoja" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Zerus": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Umoja" },
|
||||
{ "scope": "..", "amount": 3 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Skygeirr Station": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Zerus" },
|
||||
{ "scope": "..", "amount": 5 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Dominion Space": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Zerus" },
|
||||
{ "scope": "..", "amount": 5 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Korhal": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Zerus" },
|
||||
{ "scope": "..", "amount": 8 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_mini_lotv_prologue = {
|
||||
"min_difficulty": "easy",
|
||||
"Prologue": {
|
||||
"display_name": "",
|
||||
"type": "gauntlet",
|
||||
"size": 2,
|
||||
"mission_pool": [
|
||||
MissionGroupNames.PROLOGUE_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
],
|
||||
"missions": [
|
||||
{ "index": 1, "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_mini_lotv = {
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.LOTV_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Aiur": {
|
||||
"size": 2,
|
||||
"missions": [
|
||||
{ "index": 1, "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Korhal": {
|
||||
"size": 1,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Aiur" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Shakuras": {
|
||||
"size": 1,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Aiur" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Purifier": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Korhal" },
|
||||
{ "scope": "../Shakuras" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 1, "entry_rules": [{ "scope": "../../Ulnar" }, { "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Ulnar": {
|
||||
"size": 1,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Purifier/0" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Tal'darim": {
|
||||
"size": 1,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Ulnar" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Return to Aiur": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Purifier" },
|
||||
{ "scope": "../Tal'darim" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_mini_lotv_epilogue = {
|
||||
"min_difficulty": "very hard",
|
||||
"Epilogue": {
|
||||
"display_name": "",
|
||||
"type": "gauntlet",
|
||||
"size": 2,
|
||||
"mission_pool": [
|
||||
MissionGroupNames.EPILOGUE_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
],
|
||||
"missions": [
|
||||
{ "index": 1, "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_mini_nco = {
|
||||
"min_difficulty": "easy",
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.NCO_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Mission Pack 1": {
|
||||
"size": 2,
|
||||
"missions": [
|
||||
{ "index": 1, "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Mission Pack 2": {
|
||||
"size": 1,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mission Pack 1" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Mission Pack 3": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mission Pack 2" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
preset_wol_with_prophecy = {
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.WOL_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Mar Sara": {
|
||||
"size": 3,
|
||||
"missions": [
|
||||
{ "index": 0, "mission_pool": SC2Mission.LIBERATION_DAY.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.THE_OUTLAWS.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.ZERO_HOUR.mission_name },
|
||||
{ "index": [1, 2], "entry_rules": [{ "items": { "Key": 1 }}] }
|
||||
]
|
||||
},
|
||||
"Colonist": {
|
||||
"size": 4,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": 1, "next": [2, 3] },
|
||||
{ "index": 2, "next": [] },
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": [2, 3], "entry_rules": [{ "scope": "../..", "amount": 7 }, { "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.EVACUATION.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.OUTBREAK.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.SAFE_HAVEN.mission_name },
|
||||
{ "index": 3, "mission_pool": SC2Mission.HAVENS_FALL.mission_name },
|
||||
]
|
||||
},
|
||||
"Artifact": {
|
||||
"size": 5,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 1, "entry_rules": [{ "scope": "../..", "amount": 8 }, { "items": { "Key": 1 }}] },
|
||||
{ "index": 2, "entry_rules": [{ "scope": "../..", "amount": 11 }, { "items": { "Key": 1 }}] },
|
||||
{ "index": 3, "entry_rules": [{ "scope": "../..", "amount": 14 }, { "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.SMASH_AND_GRAB.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.THE_DIG.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.THE_MOEBIUS_FACTOR.mission_name },
|
||||
{ "index": 3, "mission_pool": SC2Mission.SUPERNOVA.mission_name },
|
||||
{ "index": 4, "mission_pool": SC2Mission.MAW_OF_THE_VOID.mission_name },
|
||||
]
|
||||
},
|
||||
"Prophecy": {
|
||||
"size": 4,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Artifact/1" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"mission_pool": [
|
||||
MissionGroupNames.PROPHECY_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.WHISPERS_OF_DOOM.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.A_SINISTER_TURN.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.ECHOES_OF_THE_FUTURE.mission_name },
|
||||
{ "index": 3, "mission_pool": SC2Mission.IN_UTTER_DARKNESS.mission_name },
|
||||
]
|
||||
},
|
||||
"Covert": {
|
||||
"size": 4,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "scope": "..", "amount": 4 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": 1, "next": [2, 3] },
|
||||
{ "index": 2, "next": [] },
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": [2, 3], "entry_rules": [{ "scope": "../..", "amount": 8 }, { "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.DEVILS_PLAYGROUND.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.WELCOME_TO_THE_JUNGLE.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.BREAKOUT.mission_name },
|
||||
{ "index": 3, "mission_pool": SC2Mission.GHOST_OF_A_CHANCE.mission_name },
|
||||
]
|
||||
},
|
||||
"Rebellion": {
|
||||
"size": 5,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mar Sara" },
|
||||
{ "scope": "..", "amount": 6 },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.THE_GREAT_TRAIN_ROBBERY.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.CUTTHROAT.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.ENGINE_OF_DESTRUCTION.mission_name },
|
||||
{ "index": 3, "mission_pool": SC2Mission.MEDIA_BLITZ.mission_name },
|
||||
{ "index": 4, "mission_pool": SC2Mission.PIERCING_OF_THE_SHROUD.mission_name },
|
||||
]
|
||||
},
|
||||
"Char": {
|
||||
"size": 4,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Artifact" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": 0, "next": [1, 2] },
|
||||
{ "index": [1, 2], "next": [3] },
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.GATES_OF_HELL.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.BELLY_OF_THE_BEAST.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.SHATTER_THE_SKY.mission_name },
|
||||
{ "index": 3, "mission_pool": SC2Mission.ALL_IN.mission_name },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_wol = copy.deepcopy(preset_wol_with_prophecy)
|
||||
preset_prophecy = { "Prophecy": preset_wol.pop("Prophecy") }
|
||||
preset_prophecy["Prophecy"].pop("entry_rules")
|
||||
preset_prophecy["Prophecy"]["type"] = "gauntlet"
|
||||
preset_prophecy["Prophecy"]["display_name"] = ""
|
||||
preset_prophecy["Prophecy"]["missions"].append({ "index": "entrances", "entry_rules": [] })
|
||||
|
||||
preset_hots = {
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.HOTS_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Umoja": {
|
||||
"size": 3,
|
||||
"missions": [
|
||||
{ "index": [1, 2], "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.LAB_RAT.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.BACK_IN_THE_SADDLE.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.RENDEZVOUS.mission_name },
|
||||
]
|
||||
},
|
||||
"Kaldir": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Umoja" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.HARVEST_OF_SCREAMS.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.SHOOT_THE_MESSENGER.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.ENEMY_WITHIN.mission_name },
|
||||
]
|
||||
},
|
||||
"Char": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Umoja" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.DOMINATION.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.FIRE_IN_THE_SKY.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.OLD_SOLDIERS.mission_name },
|
||||
]
|
||||
},
|
||||
"Zerus": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{
|
||||
"rules": [
|
||||
{ "scope": "../Kaldir" },
|
||||
{ "scope": "../Char" }
|
||||
],
|
||||
"amount": 1
|
||||
},
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.WAKING_THE_ANCIENT.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.THE_CRUCIBLE.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.SUPREME.mission_name },
|
||||
]
|
||||
},
|
||||
"Skygeirr Station": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": ["../Kaldir", "../Char", "../Zerus"] },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.INFESTED.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.HAND_OF_DARKNESS.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.PHANTOMS_OF_THE_VOID.mission_name },
|
||||
]
|
||||
},
|
||||
"Dominion Space": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": ["../Kaldir", "../Char", "../Zerus"] },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.WITH_FRIENDS_LIKE_THESE.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.CONVICTION.mission_name },
|
||||
]
|
||||
},
|
||||
"Korhal": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": ["../Skygeirr Station", "../Dominion Space"] },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.PLANETFALL.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.DEATH_FROM_ABOVE.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.THE_RECKONING.mission_name },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_lotv_prologue = {
|
||||
"min_difficulty": "easy",
|
||||
"Prologue": {
|
||||
"display_name": "",
|
||||
"type": "gauntlet",
|
||||
"size": 3,
|
||||
"mission_pool": [
|
||||
MissionGroupNames.PROLOGUE_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
],
|
||||
"missions": [
|
||||
{ "index": [1, 2], "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.DARK_WHISPERS.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.GHOSTS_IN_THE_FOG.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.EVIL_AWOKEN.mission_name },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_lotv = {
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.LOTV_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Aiur": {
|
||||
"size": 3,
|
||||
"missions": [
|
||||
{ "index": [1, 2], "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.FOR_AIUR.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.THE_GROWING_SHADOW.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.THE_SPEAR_OF_ADUN.mission_name },
|
||||
]
|
||||
},
|
||||
"Korhal": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Aiur" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.SKY_SHIELD.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.BROTHERS_IN_ARMS.mission_name },
|
||||
]
|
||||
},
|
||||
"Shakuras": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Aiur" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.AMON_S_REACH.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.LAST_STAND.mission_name },
|
||||
]
|
||||
},
|
||||
"Purifier": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{
|
||||
"rules": [
|
||||
{ "scope": "../Korhal" },
|
||||
{ "scope": "../Shakuras" }
|
||||
],
|
||||
"amount": 1
|
||||
},
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 1, "entry_rules": [{ "scope": "../../Ulnar" }, { "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.FORBIDDEN_WEAPON.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.UNSEALING_THE_PAST.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.PURIFICATION.mission_name },
|
||||
]
|
||||
},
|
||||
"Ulnar": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{
|
||||
"scope": [
|
||||
"../Korhal",
|
||||
"../Shakuras",
|
||||
"../Purifier/0"
|
||||
]
|
||||
},
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.TEMPLE_OF_UNIFICATION.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.THE_INFINITE_CYCLE.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.HARBINGER_OF_OBLIVION.mission_name },
|
||||
]
|
||||
},
|
||||
"Tal'darim": {
|
||||
"size": 2,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Ulnar" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.STEPS_OF_THE_RITE.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.RAK_SHIR.mission_name },
|
||||
]
|
||||
},
|
||||
"Moebius": {
|
||||
"size": 1,
|
||||
"entry_rules": [
|
||||
{
|
||||
"rules": [
|
||||
{ "scope": "../Purifier" },
|
||||
{ "scope": "../Tal'darim" }
|
||||
],
|
||||
"amount": 1
|
||||
},
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.TEMPLAR_S_CHARGE.mission_name },
|
||||
]
|
||||
},
|
||||
"Return to Aiur": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Purifier" },
|
||||
{ "scope": "../Tal'darim" },
|
||||
{ "scope": "../Moebius" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.TEMPLAR_S_RETURN.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.THE_HOST.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.SALVATION.mission_name },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_lotv_epilogue = {
|
||||
"min_difficulty": "very hard",
|
||||
"Epilogue": {
|
||||
"display_name": "",
|
||||
"type": "gauntlet",
|
||||
"size": 3,
|
||||
"mission_pool": [
|
||||
MissionGroupNames.EPILOGUE_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
],
|
||||
"missions": [
|
||||
{ "index": [1, 2], "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.INTO_THE_VOID.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.THE_ESSENCE_OF_ETERNITY.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.AMON_S_FALL.mission_name },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
preset_nco = {
|
||||
"min_difficulty": "easy",
|
||||
"global": {
|
||||
"type": "column",
|
||||
"mission_pool": [
|
||||
MissionGroupNames.NCO_MISSIONS,
|
||||
"~ " + MissionGroupNames.RACESWAP_MISSIONS
|
||||
]
|
||||
},
|
||||
"Mission Pack 1": {
|
||||
"size": 3,
|
||||
"missions": [
|
||||
{ "index": [1, 2], "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.THE_ESCAPE.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.SUDDEN_STRIKE.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.ENEMY_INTELLIGENCE.mission_name },
|
||||
]
|
||||
},
|
||||
"Mission Pack 2": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mission Pack 1" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.TROUBLE_IN_PARADISE.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.NIGHT_TERRORS.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.FLASHPOINT.mission_name },
|
||||
]
|
||||
},
|
||||
"Mission Pack 3": {
|
||||
"size": 3,
|
||||
"entry_rules": [
|
||||
{ "scope": "../Mission Pack 2" },
|
||||
{ "items": { "Key": 1 }}
|
||||
],
|
||||
"missions": [
|
||||
{ "index": "all", "entry_rules": [{ "items": { "Key": 1 }}] },
|
||||
{ "index": 0, "mission_pool": SC2Mission.IN_THE_ENEMY_S_SHADOW.mission_name },
|
||||
{ "index": 1, "mission_pool": SC2Mission.DARK_SKIES.mission_name },
|
||||
{ "index": 2, "mission_pool": SC2Mission.END_GAME.mission_name },
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
def _build_static_preset(preset: Dict[str, Any], options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Raceswap shuffling
|
||||
raceswaps = options.pop("shuffle_raceswaps", False)
|
||||
if not isinstance(raceswaps, bool):
|
||||
raise ValueError(
|
||||
f"Preset option \"shuffle_raceswaps\" received unknown value \"{raceswaps}\".\n"
|
||||
"Valid values are: true, false"
|
||||
)
|
||||
elif raceswaps == True:
|
||||
# Remove "~ Raceswap Missions" operation from mission pool options
|
||||
# Also add raceswap variants to plando'd vanilla missions
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict:
|
||||
# Currently mission pools in layouts are always ["X campaign missions", "~ raceswap missions"]
|
||||
layout_mission_pool: List[str] = layout.get("mission_pool", None)
|
||||
if layout_mission_pool is not None:
|
||||
layout_mission_pool.pop()
|
||||
layout["mission_pool"] = layout_mission_pool
|
||||
if "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
# Currently mission pools in slots are always strings
|
||||
slot_mission_pool: str = slot.get("mission_pool", None)
|
||||
# Identify raceswappable missions by their race in brackets
|
||||
if slot_mission_pool is not None and slot_mission_pool[-1] == ")":
|
||||
mission_name = slot_mission_pool[:slot_mission_pool.rfind("(")]
|
||||
new_mission_pool = [f"{mission_name}({race})" for race in ["Terran", "Zerg", "Protoss"]]
|
||||
slot["mission_pool"] = new_mission_pool
|
||||
# The presets are set up for no raceswaps, so raceswaps == False doesn't need to be covered
|
||||
|
||||
# Mission pool selection
|
||||
missions = options.pop("missions", "random")
|
||||
if missions == "vanilla":
|
||||
pass # use preset as it is
|
||||
elif missions == "vanilla_shuffled":
|
||||
# remove pre-set missions
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict and "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
slot.pop("mission_pool", ())
|
||||
elif missions == "random":
|
||||
# remove pre-set missions and mission pools
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict:
|
||||
layout.pop("mission_pool", ())
|
||||
if "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
slot.pop("mission_pool", ())
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Preset option \"missions\" received unknown value \"{missions}\".\n"
|
||||
"Valid values are: random, vanilla, vanilla_shuffled"
|
||||
)
|
||||
|
||||
# Key rule selection
|
||||
keys = options.pop("keys", "none")
|
||||
if keys == "layouts":
|
||||
# remove keys from mission entry rules
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict and "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"] = _remove_key_rules(slot["entry_rules"])
|
||||
elif keys == "missions":
|
||||
# remove keys from layout entry rules
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict and "entry_rules" in layout:
|
||||
layout["entry_rules"] = _remove_key_rules(layout["entry_rules"])
|
||||
elif keys == "progressive_layouts":
|
||||
# remove keys from mission entry rules, replace keys in layout entry rules with unique-track keys
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict:
|
||||
if "entry_rules" in layout:
|
||||
layout["entry_rules"] = _make_key_rules_progressive(layout["entry_rules"], 0)
|
||||
if "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"] = _remove_key_rules(slot["entry_rules"])
|
||||
elif keys == "progressive_missions":
|
||||
# remove keys from layout entry rules, replace keys in mission entry rules
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict:
|
||||
if "entry_rules" in layout:
|
||||
layout["entry_rules"] = _remove_key_rules(layout["entry_rules"])
|
||||
if "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"] = _make_key_rules_progressive(slot["entry_rules"], 1)
|
||||
elif keys == "progressive_per_layout":
|
||||
# remove keys from layout entry rules, replace keys in mission entry rules with unique-track keys
|
||||
# specifically ignore layouts that have no entry rules (and are thus the first of their campaign)
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict and "entry_rules" in layout:
|
||||
layout["entry_rules"] = _remove_key_rules(layout["entry_rules"])
|
||||
if "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"] = _make_key_rules_progressive(slot["entry_rules"], 0)
|
||||
elif keys == "none":
|
||||
# remove keys from both layout and mission entry rules
|
||||
for layout in preset.values():
|
||||
if type(layout) == dict:
|
||||
if "entry_rules" in layout:
|
||||
layout["entry_rules"] = _remove_key_rules(layout["entry_rules"])
|
||||
if "missions" in layout:
|
||||
for slot in layout["missions"]:
|
||||
if "entry_rules" in slot:
|
||||
slot["entry_rules"] = _remove_key_rules(slot["entry_rules"])
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Preset option \"keys\" received unknown value \"{keys}\".\n"
|
||||
"Valid values are: none, missions, layouts, progressive_missions, progressive_layouts, progressive_per_layout"
|
||||
)
|
||||
|
||||
return preset
|
||||
|
||||
def _remove_key_rules(entry_rules: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return [rule for rule in entry_rules if not ("items" in rule and "Key" in rule["items"])]
|
||||
|
||||
def _make_key_rules_progressive(entry_rules: List[Dict[str, Any]], track: int) -> List[Dict[str, Any]]:
|
||||
for rule in entry_rules:
|
||||
if "items" in rule and "Key" in rule["items"]:
|
||||
new_items: Dict[str, Any] = {}
|
||||
for (item, amount) in rule["items"].items():
|
||||
if item == "Key":
|
||||
new_items["Progressive Key"] = track
|
||||
else:
|
||||
new_items[item] = amount
|
||||
rule["items"] = new_items
|
||||
return entry_rules
|
||||
|
||||
def static_preset(preset: Dict[str, Any]) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
|
||||
return lambda options: _build_static_preset(copy.deepcopy(preset), options)
|
||||
|
||||
def get_used_layout_names() -> Dict[SC2Campaign, Tuple[int, List[str]]]:
|
||||
campaign_to_preset: Dict[SC2Campaign, Dict[str, Any]] = {
|
||||
SC2Campaign.WOL: preset_wol_with_prophecy,
|
||||
SC2Campaign.PROPHECY: preset_prophecy,
|
||||
SC2Campaign.HOTS: preset_hots,
|
||||
SC2Campaign.PROLOGUE: preset_lotv_prologue,
|
||||
SC2Campaign.LOTV: preset_lotv,
|
||||
SC2Campaign.EPILOGUE: preset_lotv_epilogue,
|
||||
SC2Campaign.NCO: preset_nco
|
||||
}
|
||||
campaign_to_layout_names: Dict[SC2Campaign, Tuple[int, List[str]]] = { SC2Campaign.GLOBAL: (0, []) }
|
||||
for campaign in SC2Campaign:
|
||||
if campaign == SC2Campaign.GLOBAL:
|
||||
continue
|
||||
previous_campaign = [prev for prev in SC2Campaign if prev.id == campaign.id - 1][0]
|
||||
previous_size = campaign_to_layout_names[previous_campaign][0]
|
||||
preset = campaign_to_preset[campaign]
|
||||
new_layouts = [value for value in preset.keys() if isinstance(preset[value], dict) and value != "global"]
|
||||
campaign_to_layout_names[campaign] = (previous_size + len(campaign_to_layout_names[previous_campaign][1]), new_layouts)
|
||||
campaign_to_layout_names.pop(SC2Campaign.GLOBAL)
|
||||
return campaign_to_layout_names
|
||||
53
worlds/sc2/mission_order/slot_data.py
Normal file
53
worlds/sc2/mission_order/slot_data.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Houses the data structures representing a mission order in slot data.
|
||||
Creating these is handled by the nodes they represent in .nodes.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import List, Protocol
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .entry_rules import SubRuleRuleData
|
||||
|
||||
class MissionOrderObjectSlotData(Protocol):
|
||||
entry_rule: SubRuleRuleData
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignSlotData:
|
||||
name: str
|
||||
entry_rule: SubRuleRuleData
|
||||
exits: List[int]
|
||||
layouts: List[LayoutSlotData]
|
||||
|
||||
@staticmethod
|
||||
def legacy(name: str, layouts: List[LayoutSlotData]) -> CampaignSlotData:
|
||||
return CampaignSlotData(name, SubRuleRuleData.empty(), [], layouts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayoutSlotData:
|
||||
name: str
|
||||
entry_rule: SubRuleRuleData
|
||||
exits: List[int]
|
||||
missions: List[List[MissionSlotData]]
|
||||
|
||||
@staticmethod
|
||||
def legacy(name: str, missions: List[List[MissionSlotData]]) -> LayoutSlotData:
|
||||
return LayoutSlotData(name, SubRuleRuleData.empty(), [], missions)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MissionSlotData:
|
||||
mission_id: int
|
||||
prev_mission_ids: List[int]
|
||||
entry_rule: SubRuleRuleData
|
||||
victory_cache_size: int = 0
|
||||
|
||||
@staticmethod
|
||||
def empty() -> MissionSlotData:
|
||||
return MissionSlotData(-1, [], SubRuleRuleData.empty())
|
||||
|
||||
@staticmethod
|
||||
def legacy(mission_id: int, prev_mission_ids: List[int], entry_rule: SubRuleRuleData) -> MissionSlotData:
|
||||
return MissionSlotData(mission_id, prev_mission_ids, entry_rule)
|
||||
Reference in New Issue
Block a user