 e0e9fdd86a
			
		
	
	e0e9fdd86a
	
	
	
		
			
			Adds HotS, LotV and NCO campaigns to SC2 game. The world's name has changed to reflect that (it's not only Wings of Liberty now) The client was patched in a way that can still join to games generated prior this change --------- Co-authored-by: Magnemania <magnemight@gmail.com> Co-authored-by: EnvyDragon <138727357+EnvyDragon@users.noreply.github.com> Co-authored-by: Matthew <matthew.marinets@gmail.com> Co-authored-by: hopop201 <benjy.hopop201@gmail.com> Co-authored-by: Salzkorn <salzkitty@gmail.com> Co-authored-by: genderdruid <pallyoffail@gmail.com> Co-authored-by: MadiMadsen <137329235+MadiMadsen@users.noreply.github.com> Co-authored-by: neocerber <neocerber@gmail.com> Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
		
			
				
	
	
		
			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
 | |
| 
 |