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:
Ziktofel
2025-09-02 17:40:58 +02:00
committed by GitHub
parent 2359cceb64
commit 5f1835c546
73 changed files with 46368 additions and 13655 deletions

View 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()

View 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()
)

View 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)

View 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)])

View 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

View 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,
)

View 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

View 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

View 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

View 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)