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 sorted(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
|
|
|