Files
Grinch-AP/worlds/sc2/mission_order/generation.py
2025-09-12 23:48:29 +02:00

740 lines
38 KiB
Python

"""
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_uses_rule = mission.entry_rule.target_amount > 0
mission_rule = mission.entry_rule.to_lambda(player)
mandatory_prereq = mission.entry_rule.find_mandatory_mission()
# Only layout entrances need to consider campaign & layout prerequisites
if mission.option_entrance:
campaign_uses_rule = campaign.entry_rule.target_amount > 0
campaign_rule = campaign.entry_rule.to_lambda(player)
layout_uses_rule = layout.entry_rule.target_amount > 0
layout_rule = layout.entry_rule.to_lambda(player)
# Any mandatory prerequisite mission is good enough
mandatory_prereq = campaign.entry_rule.find_mandatory_mission() if mandatory_prereq is None else mandatory_prereq
mandatory_prereq = layout.entry_rule.find_mandatory_mission() if mandatory_prereq is None else mandatory_prereq
# Avoid calling obviously unused lambdas
if campaign_uses_rule:
if layout_uses_rule:
if mission_uses_rule:
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 = lambda state, campaign_rule=campaign_rule, layout_rule=layout_rule: \
campaign_rule(state) and layout_rule(state)
else:
if mission_uses_rule:
unlock_rule = lambda state, campaign_rule=campaign_rule, mission_rule=mission_rule: \
campaign_rule(state) and mission_rule(state)
else:
unlock_rule = campaign_rule
elif layout_uses_rule:
if mission_uses_rule:
unlock_rule = lambda state, layout_rule=layout_rule, mission_rule=mission_rule: \
layout_rule(state) and mission_rule(state)
else:
unlock_rule = layout_rule
elif mission_uses_rule:
unlock_rule = mission_rule
else:
unlock_rule = None
elif mission_uses_rule:
unlock_rule = mission_rule
else:
unlock_rule = None
# Connect to a discovered mandatory mission if possible
if mandatory_prereq is not None:
connect(world, names, mandatory_prereq.mission.mission_name, mission.mission.mission_name, unlock_rule)
else:
# If no mission is known to be mandatory, connect to all previous missions instead
for prev_mission in mission.prev:
connect(world, names, prev_mission.mission.mission_name, mission.mission.mission_name, unlock_rule)
# As a last resort connect to Menu
if len(mission.prev) == 0:
connect(world, names, "Menu", mission.mission.mission_name, unlock_rule)
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)