692 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
		
		
			
		
	
	
			692 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
|   | from typing import List, Dict, Tuple, Optional, Callable, NamedTuple, Union | ||
|  | import math | ||
|  | 
 | ||
|  | from BaseClasses import MultiWorld, Region, Entrance, Location, CollectionState | ||
|  | from .Locations import LocationData | ||
|  | from .Options import get_option_value, MissionOrder, get_enabled_campaigns, campaign_depending_orders, \ | ||
|  |     GridTwoStartPositions | ||
|  | from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, \ | ||
|  |     MissionPools, SC2Campaign, get_goal_location, SC2Mission, MissionConnection | ||
|  | from .PoolFilter import filter_missions | ||
|  | from worlds.AutoWorld import World | ||
|  | 
 | ||
|  | 
 | ||
|  | class SC2MissionSlot(NamedTuple): | ||
|  |     campaign: SC2Campaign | ||
|  |     slot: Union[MissionPools, SC2Mission, None] | ||
|  | 
 | ||
|  | 
 | ||
|  | def create_regions( | ||
|  |     world: World, locations: Tuple[LocationData, ...], location_cache: List[Location] | ||
|  | ) -> Tuple[Dict[SC2Campaign, Dict[str, MissionInfo]], int, str]: | ||
|  |     """
 | ||
|  |     Creates region connections by calling the multiworld's `connect()` methods | ||
|  |     Returns a 3-tuple containing: | ||
|  |     * dict[SC2Campaign, Dict[str, MissionInfo]] mapping a campaign and mission name to its data | ||
|  |     * int The number of missions in the world | ||
|  |     * str The name of the goal location | ||
|  |     """
 | ||
|  |     mission_order_type: int = get_option_value(world, "mission_order") | ||
|  | 
 | ||
|  |     if mission_order_type == MissionOrder.option_vanilla: | ||
|  |         return create_vanilla_regions(world, locations, location_cache) | ||
|  |     elif mission_order_type == MissionOrder.option_grid: | ||
|  |         return create_grid_regions(world, locations, location_cache) | ||
|  |     else: | ||
|  |         return create_structured_regions(world, locations, location_cache, mission_order_type) | ||
|  | 
 | ||
|  | def create_vanilla_regions( | ||
|  |     world: World, | ||
|  |     locations: Tuple[LocationData, ...], | ||
|  |     location_cache: List[Location], | ||
|  | ) -> Tuple[Dict[SC2Campaign, Dict[str, MissionInfo]], int, str]: | ||
|  |     locations_per_region = get_locations_per_region(locations) | ||
|  |     regions = [create_region(world, locations_per_region, location_cache, "Menu")] | ||
|  | 
 | ||
|  |     mission_pools: Dict[MissionPools, List[SC2Mission]] = filter_missions(world) | ||
|  |     final_mission = mission_pools[MissionPools.FINAL][0] | ||
|  | 
 | ||
|  |     enabled_campaigns = get_enabled_campaigns(world) | ||
|  |     names: Dict[str, int] = {} | ||
|  | 
 | ||
|  |     # Generating all regions and locations for each enabled campaign | ||
|  |     for campaign in enabled_campaigns: | ||
|  |         for region_name in vanilla_mission_req_table[campaign].keys(): | ||
|  |             regions.append(create_region(world, locations_per_region, location_cache, region_name)) | ||
|  |     world.multiworld.regions += regions | ||
|  |     vanilla_mission_reqs = {campaign: missions for campaign, missions in vanilla_mission_req_table.items() if campaign in enabled_campaigns} | ||
|  | 
 | ||
|  |     def wol_cleared_missions(state: CollectionState, mission_count: int) -> bool: | ||
|  |         return state.has_group("WoL Missions", world.player, mission_count) | ||
|  |      | ||
|  |     player: int = world.player | ||
|  |     if SC2Campaign.WOL in enabled_campaigns: | ||
|  |         connect(world, names, 'Menu', 'Liberation Day') | ||
|  |         connect(world, names, 'Liberation Day', 'The Outlaws', | ||
|  |                 lambda state: state.has("Beat Liberation Day", player)) | ||
|  |         connect(world, names, 'The Outlaws', 'Zero Hour', | ||
|  |                 lambda state: state.has("Beat The Outlaws", player)) | ||
|  |         connect(world, names, 'Zero Hour', 'Evacuation', | ||
|  |                 lambda state: state.has("Beat Zero Hour", player)) | ||
|  |         connect(world, names, 'Evacuation', 'Outbreak', | ||
|  |                 lambda state: state.has("Beat Evacuation", player)) | ||
|  |         connect(world, names, "Outbreak", "Safe Haven", | ||
|  |                 lambda state: wol_cleared_missions(state, 7) and state.has("Beat Outbreak", player)) | ||
|  |         connect(world, names, "Outbreak", "Haven's Fall", | ||
|  |                 lambda state: wol_cleared_missions(state, 7) and state.has("Beat Outbreak", player)) | ||
|  |         connect(world, names, 'Zero Hour', 'Smash and Grab', | ||
|  |                 lambda state: state.has("Beat Zero Hour", player)) | ||
|  |         connect(world, names, 'Smash and Grab', 'The Dig', | ||
|  |                 lambda state: wol_cleared_missions(state, 8) and state.has("Beat Smash and Grab", player)) | ||
|  |         connect(world, names, 'The Dig', 'The Moebius Factor', | ||
|  |                 lambda state: wol_cleared_missions(state, 11) and state.has("Beat The Dig", player)) | ||
|  |         connect(world, names, 'The Moebius Factor', 'Supernova', | ||
|  |                 lambda state: wol_cleared_missions(state, 14) and state.has("Beat The Moebius Factor", player)) | ||
|  |         connect(world, names, 'Supernova', 'Maw of the Void', | ||
|  |                 lambda state: state.has("Beat Supernova", player)) | ||
|  |         connect(world, names, 'Zero Hour', "Devil's Playground", | ||
|  |                 lambda state: wol_cleared_missions(state, 4) and state.has("Beat Zero Hour", player)) | ||
|  |         connect(world, names, "Devil's Playground", 'Welcome to the Jungle', | ||
|  |                 lambda state: state.has("Beat Devil's Playground", player)) | ||
|  |         connect(world, names, "Welcome to the Jungle", 'Breakout', | ||
|  |                 lambda state: wol_cleared_missions(state, 8) and state.has("Beat Welcome to the Jungle", player)) | ||
|  |         connect(world, names, "Welcome to the Jungle", 'Ghost of a Chance', | ||
|  |                 lambda state: wol_cleared_missions(state, 8) and state.has("Beat Welcome to the Jungle", player)) | ||
|  |         connect(world, names, "Zero Hour", 'The Great Train Robbery', | ||
|  |                 lambda state: wol_cleared_missions(state, 6) and state.has("Beat Zero Hour", player)) | ||
|  |         connect(world, names, 'The Great Train Robbery', 'Cutthroat', | ||
|  |                 lambda state: state.has("Beat The Great Train Robbery", player)) | ||
|  |         connect(world, names, 'Cutthroat', 'Engine of Destruction', | ||
|  |                 lambda state: state.has("Beat Cutthroat", player)) | ||
|  |         connect(world, names, 'Engine of Destruction', 'Media Blitz', | ||
|  |                 lambda state: state.has("Beat Engine of Destruction", player)) | ||
|  |         connect(world, names, 'Media Blitz', 'Piercing the Shroud', | ||
|  |                 lambda state: state.has("Beat Media Blitz", player)) | ||
|  |         connect(world, names, 'Maw of the Void', 'Gates of Hell', | ||
|  |                 lambda state: state.has("Beat Maw of the Void", player)) | ||
|  |         connect(world, names, 'Gates of Hell', 'Belly of the Beast', | ||
|  |                 lambda state: state.has("Beat Gates of Hell", player)) | ||
|  |         connect(world, names, 'Gates of Hell', 'Shatter the Sky', | ||
|  |                 lambda state: state.has("Beat Gates of Hell", player)) | ||
|  |         connect(world, names, 'Gates of Hell', 'All-In', | ||
|  |                 lambda state: state.has('Beat Gates of Hell', player) and ( | ||
|  |                         state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player))) | ||
|  | 
 | ||
|  |     if SC2Campaign.PROPHECY in enabled_campaigns: | ||
|  |         if SC2Campaign.WOL in enabled_campaigns: | ||
|  |             connect(world, names, 'The Dig', 'Whispers of Doom', | ||
|  |                     lambda state: state.has("Beat The Dig", player)), | ||
|  |         else: | ||
|  |             vanilla_mission_reqs[SC2Campaign.PROPHECY] = vanilla_mission_reqs[SC2Campaign.PROPHECY].copy() | ||
|  |             vanilla_mission_reqs[SC2Campaign.PROPHECY][SC2Mission.WHISPERS_OF_DOOM.mission_name] = MissionInfo( | ||
|  |                 SC2Mission.WHISPERS_OF_DOOM, [], SC2Mission.WHISPERS_OF_DOOM.area) | ||
|  |             connect(world, names, 'Menu', 'Whispers of Doom'), | ||
|  |         connect(world, names, 'Whispers of Doom', 'A Sinister Turn', | ||
|  |                 lambda state: state.has("Beat Whispers of Doom", player)) | ||
|  |         connect(world, names, 'A Sinister Turn', 'Echoes of the Future', | ||
|  |                 lambda state: state.has("Beat A Sinister Turn", player)) | ||
|  |         connect(world, names, 'Echoes of the Future', 'In Utter Darkness', | ||
|  |                 lambda state: state.has("Beat Echoes of the Future", player)) | ||
|  | 
 | ||
|  |     if SC2Campaign.HOTS in enabled_campaigns: | ||
|  |         connect(world, names, 'Menu', 'Lab Rat'), | ||
|  |         connect(world, names, 'Lab Rat', 'Back in the Saddle', | ||
|  |                 lambda state: state.has("Beat Lab Rat", player)), | ||
|  |         connect(world, names, 'Back in the Saddle', 'Rendezvous', | ||
|  |                 lambda state: state.has("Beat Back in the Saddle", player)), | ||
|  |         connect(world, names, 'Rendezvous', 'Harvest of Screams', | ||
|  |                 lambda state: state.has("Beat Rendezvous", player)), | ||
|  |         connect(world, names, 'Harvest of Screams', 'Shoot the Messenger', | ||
|  |                 lambda state: state.has("Beat Harvest of Screams", player)), | ||
|  |         connect(world, names, 'Shoot the Messenger', 'Enemy Within', | ||
|  |                 lambda state: state.has("Beat Shoot the Messenger", player)), | ||
|  |         connect(world, names, 'Rendezvous', 'Domination', | ||
|  |                 lambda state: state.has("Beat Rendezvous", player)), | ||
|  |         connect(world, names, 'Domination', 'Fire in the Sky', | ||
|  |                 lambda state: state.has("Beat Domination", player)), | ||
|  |         connect(world, names, 'Fire in the Sky', 'Old Soldiers', | ||
|  |                 lambda state: state.has("Beat Fire in the Sky", player)), | ||
|  |         connect(world, names, 'Old Soldiers', 'Waking the Ancient', | ||
|  |                 lambda state: state.has("Beat Old Soldiers", player)), | ||
|  |         connect(world, names, 'Enemy Within', 'Waking the Ancient', | ||
|  |                 lambda state: state.has("Beat Enemy Within", player)), | ||
|  |         connect(world, names, 'Waking the Ancient', 'The Crucible', | ||
|  |                 lambda state: state.has("Beat Waking the Ancient", player)), | ||
|  |         connect(world, names, 'The Crucible', 'Supreme', | ||
|  |                 lambda state: state.has("Beat The Crucible", player)), | ||
|  |         connect(world, names, 'Supreme', 'Infested', | ||
|  |                 lambda state: state.has("Beat Supreme", player) and | ||
|  |                             state.has("Beat Old Soldiers", player) and | ||
|  |                             state.has("Beat Enemy Within", player)), | ||
|  |         connect(world, names, 'Infested', 'Hand of Darkness', | ||
|  |                 lambda state: state.has("Beat Infested", player)), | ||
|  |         connect(world, names, 'Hand of Darkness', 'Phantoms of the Void', | ||
|  |                 lambda state: state.has("Beat Hand of Darkness", player)), | ||
|  |         connect(world, names, 'Supreme', 'With Friends Like These', | ||
|  |                 lambda state: state.has("Beat Supreme", player) and | ||
|  |                             state.has("Beat Old Soldiers", player) and | ||
|  |                             state.has("Beat Enemy Within", player)), | ||
|  |         connect(world, names, 'With Friends Like These', 'Conviction', | ||
|  |                 lambda state: state.has("Beat With Friends Like These", player)), | ||
|  |         connect(world, names, 'Conviction', 'Planetfall', | ||
|  |                 lambda state: state.has("Beat Conviction", player) and | ||
|  |                             state.has("Beat Phantoms of the Void", player)), | ||
|  |         connect(world, names, 'Planetfall', 'Death From Above', | ||
|  |                 lambda state: state.has("Beat Planetfall", player)), | ||
|  |         connect(world, names, 'Death From Above', 'The Reckoning', | ||
|  |                 lambda state: state.has("Beat Death From Above", player)), | ||
|  | 
 | ||
|  |     if SC2Campaign.PROLOGUE in enabled_campaigns: | ||
|  |         connect(world, names, "Menu", "Dark Whispers") | ||
|  |         connect(world, names, "Dark Whispers", "Ghosts in the Fog", | ||
|  |                 lambda state: state.has("Beat Dark Whispers", player)) | ||
|  |         connect(world, names, "Dark Whispers", "Evil Awoken", | ||
|  |                 lambda state: state.has("Beat Ghosts in the Fog", player)) | ||
|  | 
 | ||
|  |     if SC2Campaign.LOTV in enabled_campaigns: | ||
|  |         connect(world, names, "Menu", "For Aiur!") | ||
|  |         connect(world, names, "For Aiur!", "The Growing Shadow", | ||
|  |                 lambda state: state.has("Beat For Aiur!", player)), | ||
|  |         connect(world, names, "The Growing Shadow", "The Spear of Adun", | ||
|  |                 lambda state: state.has("Beat The Growing Shadow", player)), | ||
|  |         connect(world, names, "The Spear of Adun", "Sky Shield", | ||
|  |                 lambda state: state.has("Beat The Spear of Adun", player)), | ||
|  |         connect(world, names, "Sky Shield", "Brothers in Arms", | ||
|  |                 lambda state: state.has("Beat Sky Shield", player)), | ||
|  |         connect(world, names, "Brothers in Arms", "Forbidden Weapon", | ||
|  |                 lambda state: state.has("Beat Brothers in Arms", player)), | ||
|  |         connect(world, names, "The Spear of Adun", "Amon's Reach", | ||
|  |                 lambda state: state.has("Beat The Spear of Adun", player)), | ||
|  |         connect(world, names, "Amon's Reach", "Last Stand", | ||
|  |                 lambda state: state.has("Beat Amon's Reach", player)), | ||
|  |         connect(world, names, "Last Stand", "Forbidden Weapon", | ||
|  |                 lambda state: state.has("Beat Last Stand", player)), | ||
|  |         connect(world, names, "Forbidden Weapon", "Temple of Unification", | ||
|  |                 lambda state: state.has("Beat Brothers in Arms", player) | ||
|  |                               and state.has("Beat Last Stand", player) | ||
|  |                               and state.has("Beat Forbidden Weapon", player)), | ||
|  |         connect(world, names, "Temple of Unification", "The Infinite Cycle", | ||
|  |                 lambda state: state.has("Beat Temple of Unification", player)), | ||
|  |         connect(world, names, "The Infinite Cycle", "Harbinger of Oblivion", | ||
|  |                 lambda state: state.has("Beat The Infinite Cycle", player)), | ||
|  |         connect(world, names, "Harbinger of Oblivion", "Unsealing the Past", | ||
|  |                 lambda state: state.has("Beat Harbinger of Oblivion", player)), | ||
|  |         connect(world, names, "Unsealing the Past", "Purification", | ||
|  |                 lambda state: state.has("Beat Unsealing the Past", player)), | ||
|  |         connect(world, names, "Purification", "Templar's Charge", | ||
|  |                 lambda state: state.has("Beat Purification", player)), | ||
|  |         connect(world, names, "Harbinger of Oblivion", "Steps of the Rite", | ||
|  |                 lambda state: state.has("Beat Harbinger of Oblivion", player)), | ||
|  |         connect(world, names, "Steps of the Rite", "Rak'Shir", | ||
|  |                 lambda state: state.has("Beat Steps of the Rite", player)), | ||
|  |         connect(world, names, "Rak'Shir", "Templar's Charge", | ||
|  |                 lambda state: state.has("Beat Rak'Shir", player)), | ||
|  |         connect(world, names, "Templar's Charge", "Templar's Return", | ||
|  |                 lambda state: state.has("Beat Purification", player) | ||
|  |                               and state.has("Beat Rak'Shir", player) | ||
|  |                               and state.has("Beat Templar's Charge", player)), | ||
|  |         connect(world, names, "Templar's Return", "The Host", | ||
|  |                 lambda state: state.has("Beat Templar's Return", player)), | ||
|  |         connect(world, names, "The Host", "Salvation", | ||
|  |                 lambda state: state.has("Beat The Host", player)), | ||
|  | 
 | ||
|  |     if SC2Campaign.EPILOGUE in enabled_campaigns: | ||
|  |         # TODO: Make this aware about excluded campaigns | ||
|  |         connect(world, names, "Salvation", "Into the Void", | ||
|  |                 lambda state: state.has("Beat Salvation", player) | ||
|  |                               and state.has("Beat The Reckoning", player) | ||
|  |                               and state.has("Beat All-In", player)), | ||
|  |         connect(world, names, "Into the Void", "The Essence of Eternity", | ||
|  |                 lambda state: state.has("Beat Into the Void", player)), | ||
|  |         connect(world, names, "The Essence of Eternity", "Amon's Fall", | ||
|  |                 lambda state: state.has("Beat The Essence of Eternity", player)), | ||
|  | 
 | ||
|  |     if SC2Campaign.NCO in enabled_campaigns: | ||
|  |         connect(world, names, "Menu", "The Escape") | ||
|  |         connect(world, names, "The Escape", "Sudden Strike", | ||
|  |                 lambda state: state.has("Beat The Escape", player)) | ||
|  |         connect(world, names, "Sudden Strike", "Enemy Intelligence", | ||
|  |                 lambda state: state.has("Beat Sudden Strike", player)) | ||
|  |         connect(world, names, "Enemy Intelligence", "Trouble In Paradise", | ||
|  |                 lambda state: state.has("Beat Enemy Intelligence", player)) | ||
|  |         connect(world, names, "Trouble In Paradise", "Night Terrors", | ||
|  |                 lambda state: state.has("Beat Evacuation", player)) | ||
|  |         connect(world, names, "Night Terrors", "Flashpoint", | ||
|  |                 lambda state: state.has("Beat Night Terrors", player)) | ||
|  |         connect(world, names, "Flashpoint", "In the Enemy's Shadow", | ||
|  |                 lambda state: state.has("Beat Flashpoint", player)) | ||
|  |         connect(world, names, "In the Enemy's Shadow", "Dark Skies", | ||
|  |                 lambda state: state.has("Beat In the Enemy's Shadow", player)) | ||
|  |         connect(world, names, "Dark Skies", "End Game", | ||
|  |                 lambda state: state.has("Beat Dark Skies", player)) | ||
|  | 
 | ||
|  |     goal_location = get_goal_location(final_mission) | ||
|  |     assert goal_location, f"Unable to find a goal location for mission {final_mission}" | ||
|  |     setup_final_location(goal_location, location_cache) | ||
|  | 
 | ||
|  |     return (vanilla_mission_reqs, final_mission.id, goal_location) | ||
|  | 
 | ||
|  | 
 | ||
|  | def create_grid_regions( | ||
|  |     world: World, | ||
|  |     locations: Tuple[LocationData, ...], | ||
|  |     location_cache: List[Location], | ||
|  | ) -> Tuple[Dict[SC2Campaign, Dict[str, MissionInfo]], int, str]: | ||
|  |     locations_per_region = get_locations_per_region(locations) | ||
|  | 
 | ||
|  |     mission_pools = filter_missions(world) | ||
|  |     final_mission = mission_pools[MissionPools.FINAL][0] | ||
|  | 
 | ||
|  |     mission_pool = [mission for mission_pool in mission_pools.values() for mission in mission_pool] | ||
|  | 
 | ||
|  |     num_missions = min(len(mission_pool), get_option_value(world, "maximum_campaign_size")) | ||
|  |     remove_top_left: bool = get_option_value(world, "grid_two_start_positions") == GridTwoStartPositions.option_true | ||
|  | 
 | ||
|  |     regions = [create_region(world, locations_per_region, location_cache, "Menu")] | ||
|  |     names: Dict[str, int] = {} | ||
|  |     missions: Dict[Tuple[int, int], SC2Mission] = {} | ||
|  | 
 | ||
|  |     grid_size_x, grid_size_y, num_corners_to_remove = get_grid_dimensions(num_missions + remove_top_left) | ||
|  |     # pick missions in order along concentric diagonals | ||
|  |     # each diagonal will have the same difficulty | ||
|  |     # this keeps long sides from possibly stealing lower-difficulty missions from future columns | ||
|  |     num_diagonals = grid_size_x + grid_size_y - 1 | ||
|  |     diagonal_difficulty = MissionPools.STARTER | ||
|  |     missions_to_add = mission_pools[MissionPools.STARTER] | ||
|  |     for diagonal in range(num_diagonals): | ||
|  |         if diagonal == num_diagonals - 1: | ||
|  |             diagonal_difficulty = MissionPools.FINAL | ||
|  |             grid_coords = (grid_size_x-1, grid_size_y-1) | ||
|  |             missions[grid_coords] = final_mission | ||
|  |             break | ||
|  |         if diagonal == 0 and remove_top_left: | ||
|  |             continue | ||
|  |         diagonal_length = min(diagonal + 1, num_diagonals - diagonal, grid_size_x, grid_size_y) | ||
|  |         if len(missions_to_add) < diagonal_length: | ||
|  |             raise Exception(f"There are not enough {diagonal_difficulty.name} missions to fill the campaign.  Please exclude fewer missions.") | ||
|  |         for i in range(diagonal_length): | ||
|  |             # (0,0) + (0,1)*diagonal + (1,-1)*i + (1,-1)*max(diagonal - grid_size_y + 1, 0) | ||
|  |             grid_coords = (i + max(diagonal - grid_size_y + 1, 0), diagonal - i - max(diagonal - grid_size_y + 1, 0)) | ||
|  |             if grid_coords == (grid_size_x - 1, 0) and num_corners_to_remove >= 2: | ||
|  |                 pass | ||
|  |             elif grid_coords == (0, grid_size_y - 1) and num_corners_to_remove >= 1: | ||
|  |                 pass | ||
|  |             else: | ||
|  |                 mission_index = world.random.randint(0, len(missions_to_add) - 1) | ||
|  |                 missions[grid_coords] = missions_to_add.pop(mission_index) | ||
|  | 
 | ||
|  |         if diagonal_difficulty < MissionPools.VERY_HARD: | ||
|  |             diagonal_difficulty = MissionPools(diagonal_difficulty.value + 1) | ||
|  |             missions_to_add.extend(mission_pools[diagonal_difficulty]) | ||
|  | 
 | ||
|  |     # Generating regions and locations from selected missions | ||
|  |     for x in range(grid_size_x): | ||
|  |         for y in range(grid_size_y): | ||
|  |             if missions.get((x, y)): | ||
|  |                 regions.append(create_region(world, locations_per_region, location_cache, missions[(x, y)].mission_name)) | ||
|  |     world.multiworld.regions += regions | ||
|  | 
 | ||
|  |     # This pattern is horrifying, why are we using the dict as an ordered dict??? | ||
|  |     slot_map: Dict[Tuple[int, int], int] = {} | ||
|  |     for index, coords in enumerate(missions): | ||
|  |         slot_map[coords] = index + 1 | ||
|  | 
 | ||
|  |     mission_req_table: Dict[str, MissionInfo] = {} | ||
|  |     for coords, mission in missions.items(): | ||
|  |         prepend_vertical = 0 | ||
|  |         if not mission: | ||
|  |             continue | ||
|  |         connections: List[MissionConnection] = [] | ||
|  |         if coords == (0, 0) or (remove_top_left and sum(coords) == 1): | ||
|  |             # Connect to the "Menu" starting region | ||
|  |             connect(world, names, "Menu", mission.mission_name) | ||
|  |         else: | ||
|  |             for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)): | ||
|  |                 connected_coords = (coords[0] + dx, coords[1] + dy) | ||
|  |                 if connected_coords in missions: | ||
|  |                     # connections.append(missions[connected_coords]) | ||
|  |                     connections.append(MissionConnection(slot_map[connected_coords])) | ||
|  |                     connect(world, names, missions[connected_coords].mission_name, mission.mission_name, | ||
|  |                             make_grid_connect_rule(missions, connected_coords, world.player), | ||
|  |                             ) | ||
|  |         if coords[1] == 1 and not missions.get((coords[0], 0)): | ||
|  |             prepend_vertical = 1 | ||
|  |         mission_req_table[mission.mission_name] = MissionInfo( | ||
|  |             mission, | ||
|  |             connections, | ||
|  |             category=f'_{coords[0] + 1}', | ||
|  |             or_requirements=True, | ||
|  |             ui_vertical_padding=prepend_vertical, | ||
|  |         ) | ||
|  | 
 | ||
|  |     final_mission_id = final_mission.id | ||
|  |     # Changing the completion condition for alternate final missions into an event | ||
|  |     final_location = get_goal_location(final_mission) | ||
|  |     setup_final_location(final_location, location_cache) | ||
|  | 
 | ||
|  |     return {SC2Campaign.GLOBAL: mission_req_table}, final_mission_id, final_location | ||
|  | 
 | ||
|  | 
 | ||
|  | def make_grid_connect_rule( | ||
|  |     missions: Dict[Tuple[int, int], SC2Mission], | ||
|  |     connected_coords: Tuple[int, int], | ||
|  |     player: int | ||
|  | ) -> Callable[[CollectionState], bool]: | ||
|  |     return lambda state: state.has(f"Beat {missions[connected_coords].mission_name}", player) | ||
|  | 
 | ||
|  | 
 | ||
|  | def create_structured_regions( | ||
|  |     world: World, | ||
|  |     locations: Tuple[LocationData, ...], | ||
|  |     location_cache: List[Location], | ||
|  |     mission_order_type: int, | ||
|  | ) -> Tuple[Dict[SC2Campaign, Dict[str, MissionInfo]], int, str]: | ||
|  |     locations_per_region = get_locations_per_region(locations) | ||
|  | 
 | ||
|  |     mission_order = mission_orders[mission_order_type]() | ||
|  |     enabled_campaigns = get_enabled_campaigns(world) | ||
|  |     shuffle_campaigns = get_option_value(world, "shuffle_campaigns") | ||
|  | 
 | ||
|  |     mission_pools: Dict[MissionPools, List[SC2Mission]] = filter_missions(world) | ||
|  |     final_mission = mission_pools[MissionPools.FINAL][0] | ||
|  | 
 | ||
|  |     regions = [create_region(world, locations_per_region, location_cache, "Menu")] | ||
|  | 
 | ||
|  |     names: Dict[str, int] = {} | ||
|  | 
 | ||
|  |     mission_slots: List[SC2MissionSlot] = [] | ||
|  |     mission_pool = [mission for mission_pool in mission_pools.values() for mission in mission_pool] | ||
|  | 
 | ||
|  |     if mission_order_type in campaign_depending_orders: | ||
|  |         # Do slot removal per campaign | ||
|  |         for campaign in enabled_campaigns: | ||
|  |             campaign_mission_pool = [mission for mission in mission_pool if mission.campaign == campaign] | ||
|  |             campaign_mission_pool_size = len(campaign_mission_pool) | ||
|  | 
 | ||
|  |             removals = len(mission_order[campaign]) - campaign_mission_pool_size | ||
|  | 
 | ||
|  |             for mission in mission_order[campaign]: | ||
|  |                 # Removing extra missions if mission pool is too small | ||
|  |                 if 0 < mission.removal_priority <= removals: | ||
|  |                     mission_slots.append(SC2MissionSlot(campaign, None)) | ||
|  |                 elif mission.type == MissionPools.FINAL: | ||
|  |                     if campaign == final_mission.campaign: | ||
|  |                         # Campaign is elected to be goal | ||
|  |                         mission_slots.append(SC2MissionSlot(campaign, final_mission)) | ||
|  |                     else: | ||
|  |                         # Not the goal, find the most difficult mission in the pool and set the difficulty | ||
|  |                         campaign_difficulty = max(mission.pool for mission in campaign_mission_pool) | ||
|  |                         mission_slots.append(SC2MissionSlot(campaign, campaign_difficulty)) | ||
|  |                 else: | ||
|  |                     mission_slots.append(SC2MissionSlot(campaign, mission.type)) | ||
|  |     else: | ||
|  |         order = mission_order[SC2Campaign.GLOBAL] | ||
|  |         # Determining if missions must be removed | ||
|  |         mission_pool_size = sum(len(mission_pool) for mission_pool in mission_pools.values()) | ||
|  |         removals = len(order) - mission_pool_size | ||
|  | 
 | ||
|  |         # Initial fill out of mission list and marking All-In mission | ||
|  |         for mission in order: | ||
|  |             # Removing extra missions if mission pool is too small | ||
|  |             if 0 < mission.removal_priority <= removals: | ||
|  |                 mission_slots.append(SC2MissionSlot(SC2Campaign.GLOBAL, None)) | ||
|  |             elif mission.type == MissionPools.FINAL: | ||
|  |                 mission_slots.append(SC2MissionSlot(SC2Campaign.GLOBAL, final_mission)) | ||
|  |             else: | ||
|  |                 mission_slots.append(SC2MissionSlot(SC2Campaign.GLOBAL, mission.type)) | ||
|  | 
 | ||
|  |     no_build_slots = [] | ||
|  |     easy_slots = [] | ||
|  |     medium_slots = [] | ||
|  |     hard_slots = [] | ||
|  |     very_hard_slots = [] | ||
|  | 
 | ||
|  |     # Search through missions to find slots needed to fill | ||
|  |     for i in range(len(mission_slots)): | ||
|  |         mission_slot = mission_slots[i] | ||
|  |         if mission_slot is None: | ||
|  |             continue | ||
|  |         if isinstance(mission_slot, SC2MissionSlot): | ||
|  |             if mission_slot.slot is None: | ||
|  |                 continue | ||
|  |             if mission_slot.slot == MissionPools.STARTER: | ||
|  |                 no_build_slots.append(i) | ||
|  |             elif mission_slot.slot == MissionPools.EASY: | ||
|  |                 easy_slots.append(i) | ||
|  |             elif mission_slot.slot == MissionPools.MEDIUM: | ||
|  |                 medium_slots.append(i) | ||
|  |             elif mission_slot.slot == MissionPools.HARD: | ||
|  |                 hard_slots.append(i) | ||
|  |             elif mission_slot.slot == MissionPools.VERY_HARD: | ||
|  |                 very_hard_slots.append(i) | ||
|  | 
 | ||
|  |     def pick_mission(slot): | ||
|  |         if shuffle_campaigns or mission_order_type not in campaign_depending_orders: | ||
|  |             # Pick a mission from any campaign | ||
|  |             filler = world.random.randint(0, len(missions_to_add) - 1) | ||
|  |             mission = missions_to_add.pop(filler) | ||
|  |             slot_campaign = mission_slots[slot].campaign | ||
|  |             mission_slots[slot] = SC2MissionSlot(slot_campaign, mission) | ||
|  |         else: | ||
|  |             # Pick a mission from required campaign | ||
|  |             slot_campaign = mission_slots[slot].campaign | ||
|  |             campaign_mission_candidates = [mission for mission in missions_to_add if mission.campaign == slot_campaign] | ||
|  |             mission = world.random.choice(campaign_mission_candidates) | ||
|  |             missions_to_add.remove(mission) | ||
|  |             mission_slots[slot] = SC2MissionSlot(slot_campaign, mission) | ||
|  | 
 | ||
|  |     # Add no_build missions to the pool and fill in no_build slots | ||
|  |     missions_to_add: List[SC2Mission] = mission_pools[MissionPools.STARTER] | ||
|  |     if len(no_build_slots) > len(missions_to_add): | ||
|  |         raise Exception("There are no valid No-Build missions.  Please exclude fewer missions.") | ||
|  |     for slot in no_build_slots: | ||
|  |         pick_mission(slot) | ||
|  | 
 | ||
|  |     # Add easy missions into pool and fill in easy slots | ||
|  |     missions_to_add = missions_to_add + mission_pools[MissionPools.EASY] | ||
|  |     if len(easy_slots) > len(missions_to_add): | ||
|  |         raise Exception("There are not enough Easy missions to fill the campaign.  Please exclude fewer missions.") | ||
|  |     for slot in easy_slots: | ||
|  |         pick_mission(slot) | ||
|  | 
 | ||
|  |     # Add medium missions into pool and fill in medium slots | ||
|  |     missions_to_add = missions_to_add + mission_pools[MissionPools.MEDIUM] | ||
|  |     if len(medium_slots) > len(missions_to_add): | ||
|  |         raise Exception("There are not enough Easy and Medium missions to fill the campaign.  Please exclude fewer missions.") | ||
|  |     for slot in medium_slots: | ||
|  |         pick_mission(slot) | ||
|  | 
 | ||
|  |     # Add hard missions into pool and fill in hard slots | ||
|  |     missions_to_add = missions_to_add + mission_pools[MissionPools.HARD] | ||
|  |     if len(hard_slots) > len(missions_to_add): | ||
|  |         raise Exception("There are not enough missions to fill the campaign.  Please exclude fewer missions.") | ||
|  |     for slot in hard_slots: | ||
|  |         pick_mission(slot) | ||
|  | 
 | ||
|  |     # Add very hard missions into pool and fill in very hard slots | ||
|  |     missions_to_add = missions_to_add + mission_pools[MissionPools.VERY_HARD] | ||
|  |     if len(very_hard_slots) > len(missions_to_add): | ||
|  |         raise Exception("There are not enough missions to fill the campaign.  Please exclude fewer missions.") | ||
|  |     for slot in very_hard_slots: | ||
|  |         pick_mission(slot) | ||
|  | 
 | ||
|  |     # Generating regions and locations from selected missions | ||
|  |     for mission_slot in mission_slots: | ||
|  |         if isinstance(mission_slot.slot, SC2Mission): | ||
|  |             regions.append(create_region(world, locations_per_region, location_cache, mission_slot.slot.mission_name)) | ||
|  |     world.multiworld.regions += regions | ||
|  | 
 | ||
|  |     campaigns: List[SC2Campaign] | ||
|  |     if mission_order_type in campaign_depending_orders: | ||
|  |         campaigns = list(enabled_campaigns) | ||
|  |     else: | ||
|  |         campaigns = [SC2Campaign.GLOBAL] | ||
|  | 
 | ||
|  |     mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]] = {} | ||
|  |     campaign_mission_slots: Dict[SC2Campaign, List[SC2MissionSlot]] = \ | ||
|  |         { | ||
|  |             campaign: [mission_slot for mission_slot in mission_slots if campaign == mission_slot.campaign] | ||
|  |             for campaign in campaigns | ||
|  |         } | ||
|  | 
 | ||
|  |     slot_map: Dict[SC2Campaign, List[int]] = dict() | ||
|  | 
 | ||
|  |     for campaign in campaigns: | ||
|  |         mission_req_table.update({campaign: dict()}) | ||
|  | 
 | ||
|  |         # Mapping original mission slots to shifted mission slots when missions are removed | ||
|  |         slot_map[campaign] = [] | ||
|  |         slot_offset = 0 | ||
|  |         for position, mission in enumerate(campaign_mission_slots[campaign]): | ||
|  |             slot_map[campaign].append(position - slot_offset + 1) | ||
|  |             if mission is None or mission.slot is None: | ||
|  |                 slot_offset += 1 | ||
|  | 
 | ||
|  |     def build_connection_rule(mission_names: List[str], missions_req: int) -> Callable: | ||
|  |         player = world.player | ||
|  |         if len(mission_names) > 1: | ||
|  |             return lambda state: state.has_all({f"Beat {name}" for name in mission_names}, player) \ | ||
|  |                                  and state.has_group("Missions", player, missions_req) | ||
|  |         else: | ||
|  |             return lambda state: state.has(f"Beat {mission_names[0]}", player) \ | ||
|  |                                  and state.has_group("Missions", player, missions_req) | ||
|  | 
 | ||
|  |     for campaign in campaigns: | ||
|  |         # Loop through missions to create requirements table and connect regions | ||
|  |         for i, mission in enumerate(campaign_mission_slots[campaign]): | ||
|  |             if mission is None or mission.slot is None: | ||
|  |                 continue | ||
|  |             connections: List[MissionConnection] = [] | ||
|  |             all_connections: List[SC2MissionSlot] = [] | ||
|  |             connection: MissionConnection | ||
|  |             for connection in mission_order[campaign][i].connect_to: | ||
|  |                 if connection.connect_to == -1: | ||
|  |                     continue | ||
|  |                 # If mission normally connects to an excluded campaign, connect to menu instead | ||
|  |                 if connection.campaign not in campaign_mission_slots: | ||
|  |                     connection.connect_to = -1 | ||
|  |                     continue | ||
|  |                 while campaign_mission_slots[connection.campaign][connection.connect_to].slot is None: | ||
|  |                     connection.connect_to -= 1 | ||
|  |                 all_connections.append(campaign_mission_slots[connection.campaign][connection.connect_to]) | ||
|  |             for connection in mission_order[campaign][i].connect_to: | ||
|  |                 if connection.connect_to == -1: | ||
|  |                     connect(world, names, "Menu", mission.slot.mission_name) | ||
|  |                 else: | ||
|  |                     required_mission = campaign_mission_slots[connection.campaign][connection.connect_to] | ||
|  |                     if ((required_mission is None or required_mission.slot is None) | ||
|  |                             and not mission_order[campaign][i].completion_critical):  # Drop non-critical null slots | ||
|  |                         continue | ||
|  |                     while required_mission is None or required_mission.slot is None:  # Substituting null slot with prior slot | ||
|  |                         connection.connect_to -= 1 | ||
|  |                         required_mission = campaign_mission_slots[connection.campaign][connection.connect_to] | ||
|  |                     required_missions = [required_mission] if mission_order[campaign][i].or_requirements else all_connections | ||
|  |                     if isinstance(required_mission.slot, SC2Mission): | ||
|  |                         required_mission_name = required_mission.slot.mission_name | ||
|  |                         required_missions_names = [mission.slot.mission_name for mission in required_missions] | ||
|  |                         connect(world, names, required_mission_name, mission.slot.mission_name, | ||
|  |                                 build_connection_rule(required_missions_names, mission_order[campaign][i].number)) | ||
|  |                         connections.append(MissionConnection(slot_map[connection.campaign][connection.connect_to], connection.campaign)) | ||
|  | 
 | ||
|  |             mission_req_table[campaign].update({mission.slot.mission_name: MissionInfo( | ||
|  |                 mission.slot, connections, mission_order[campaign][i].category, | ||
|  |                 number=mission_order[campaign][i].number, | ||
|  |                 completion_critical=mission_order[campaign][i].completion_critical, | ||
|  |                 or_requirements=mission_order[campaign][i].or_requirements)}) | ||
|  | 
 | ||
|  |     final_mission_id = final_mission.id | ||
|  |     # Changing the completion condition for alternate final missions into an event | ||
|  |     final_location = get_goal_location(final_mission) | ||
|  |     setup_final_location(final_location, location_cache) | ||
|  | 
 | ||
|  |     return mission_req_table, final_mission_id, final_location | ||
|  | 
 | ||
|  | 
 | ||
|  | def setup_final_location(final_location, location_cache): | ||
|  |     # Final location should be near the end of the cache | ||
|  |     for i in range(len(location_cache) - 1, -1, -1): | ||
|  |         if location_cache[i].name == final_location: | ||
|  |             location_cache[i].address = None | ||
|  |             break | ||
|  | 
 | ||
|  | 
 | ||
|  | 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_region(world: World, locations_per_region: Dict[str, List[LocationData]], | ||
|  |                   location_cache: List[Location], name: str) -> Region: | ||
|  |     region = Region(name, world.player, world.multiworld) | ||
|  | 
 | ||
|  |     if name in locations_per_region: | ||
|  |         for location_data in locations_per_region[name]: | ||
|  |             location = create_location(world.player, location_data, region, location_cache) | ||
|  |             region.locations.append(location) | ||
|  | 
 | ||
|  |     return region | ||
|  | 
 | ||
|  | 
 | ||
|  | def connect(world: World, 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 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 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 | ||
|  | 
 | ||
|  | 
 | ||
|  | 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]] = [(*get_factors(size + x), x) for x in (2, 1, 0)] | ||
|  |     best_dimension = min(dimension_candidates, key=sum) | ||
|  |     return best_dimension | ||
|  | 
 |