Archipelago/worlds/sc2/Regions.py

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, "Ghosts in the Fog", "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 Trouble In Paradise", 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