740 lines
38 KiB
Python
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)
|