forked from mirror/Archipelago
SC2: fix supreme logic hole (#5768)
* sc2: Fixing a discrepancy between slot data and logic where story tech would not be granted for supreme if zerg was not a selected race. * sc2: Fixed an issue where Kinetic Blast was not listed as a vanilla Kerrigan ability * sc2: Fixing some functions that could force Kerrigan items into the pool when playing Kerriganless * sc2: excluding zerg excludes hots for vanilla-like mission order * Preprocessing options * Moving general empty selection handling to option preprocessing * Adding a unit test for empty race/campaign selection * sc2: Properly handling non-raceswapped campaigns when excluding campaigns based on race exclusions * sc2: Adding an explicit error message if a user excludes all missions in a way with no obvious resolution
This commit is contained in:
@@ -12,8 +12,10 @@ from .item.item_tables import (
|
||||
get_full_item_list,
|
||||
not_balanced_starting_units, WEAPON_ARMOR_UPGRADE_MAX_LEVEL,
|
||||
)
|
||||
from .item import FilterItem, ItemFilterFlags, StarcraftItem, item_groups, item_names, item_tables, item_parents, \
|
||||
from .item import (
|
||||
FilterItem, ItemFilterFlags, StarcraftItem, item_groups, item_names, item_tables, item_parents,
|
||||
ZergItemType, ProtossItemType, ItemData
|
||||
)
|
||||
from .locations import (
|
||||
get_locations, DEFAULT_LOCATION_LIST, get_location_types, get_location_flags,
|
||||
get_plando_locations, LocationType, lookup_location_id_to_type
|
||||
@@ -117,17 +119,48 @@ class SC2World(World):
|
||||
data = get_full_item_list()[name]
|
||||
return StarcraftItem(name, data.classification, data.code, self.player)
|
||||
|
||||
def create_regions(self):
|
||||
def generate_early(self) -> None:
|
||||
# Do some options validation/recovery here
|
||||
if not self.options.selected_races.value:
|
||||
self.options.selected_races.value = set(options.SelectedRaces.default)
|
||||
if not self.options.enabled_campaigns.value:
|
||||
self.options.enabled_campaigns.value = set(options.EnabledCampaigns.default)
|
||||
|
||||
# Disable campaigns on vanilla-like mission orders if their race is disabled
|
||||
if self.options.mission_order.value in options.static_mission_orders:
|
||||
enabled_campaigns = set(self.options.enabled_campaigns.value)
|
||||
if self.options.enable_race_swap.value == options.EnableRaceSwapVariants.option_disabled:
|
||||
if SC2Race.TERRAN.get_title() not in self.options.selected_races.value:
|
||||
enabled_campaigns.discard(SC2Campaign.WOL.campaign_name)
|
||||
if SC2Race.ZERG.get_title() not in self.options.selected_races.value:
|
||||
enabled_campaigns.discard(SC2Campaign.HOTS.campaign_name)
|
||||
if SC2Race.PROTOSS.get_title() not in self.options.selected_races.value:
|
||||
enabled_campaigns.discard(SC2Campaign.PROPHECY.campaign_name)
|
||||
enabled_campaigns.discard(SC2Campaign.PROLOGUE.campaign_name)
|
||||
enabled_campaigns.discard(SC2Campaign.LOTV.campaign_name)
|
||||
# Epilogue and NCO don't have raceswaps currently
|
||||
if SC2Race.TERRAN.get_title() not in self.options.selected_races.value:
|
||||
enabled_campaigns.discard(SC2Campaign.NCO.campaign_name)
|
||||
if len(self.options.selected_races.value) < 3:
|
||||
enabled_campaigns.discard(SC2Campaign.EPILOGUE.campaign_name)
|
||||
if not enabled_campaigns:
|
||||
raise OptionError(
|
||||
"Campaign and race exclusions remove all possible missions from the pool. "
|
||||
"Either include more campaigns, include more races, or enable race swap."
|
||||
)
|
||||
self.options.enabled_campaigns.value = enabled_campaigns
|
||||
|
||||
def create_regions(self) -> None:
|
||||
self.logic = SC2Logic(self)
|
||||
self.custom_mission_order = create_mission_order(
|
||||
self, get_locations(self), self.location_cache
|
||||
)
|
||||
self.logic.nova_used = (
|
||||
MissionFlag.Nova in self.custom_mission_order.get_used_flags()
|
||||
or (
|
||||
MissionFlag.WoLNova in self.custom_mission_order.get_used_flags()
|
||||
and self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco
|
||||
)
|
||||
MissionFlag.Nova in self.custom_mission_order.get_used_flags()
|
||||
or (
|
||||
MissionFlag.WoLNova in self.custom_mission_order.get_used_flags()
|
||||
and self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco
|
||||
)
|
||||
)
|
||||
|
||||
def create_items(self) -> None:
|
||||
@@ -208,14 +241,14 @@ class SC2World(World):
|
||||
enabled_campaigns = get_enabled_campaigns(self)
|
||||
slot_data["plando_locations"] = get_plando_locations(self)
|
||||
slot_data["use_nova_nco_fallback"] = (
|
||||
enabled_campaigns == {SC2Campaign.NCO}
|
||||
and self.options.mission_order == MissionOrder.option_vanilla
|
||||
enabled_campaigns == {SC2Campaign.NCO}
|
||||
and self.options.mission_order == MissionOrder.option_vanilla
|
||||
)
|
||||
if (self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco
|
||||
or (
|
||||
self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_auto
|
||||
and MissionFlag.Nova in self.custom_mission_order.get_used_flags().keys()
|
||||
)
|
||||
or (
|
||||
self.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_auto
|
||||
and MissionFlag.Nova in self.custom_mission_order.get_used_flags().keys()
|
||||
)
|
||||
):
|
||||
slot_data["use_nova_wol_fallback"] = False
|
||||
else:
|
||||
@@ -224,7 +257,9 @@ class SC2World(World):
|
||||
slot_data["custom_mission_order"] = self.custom_mission_order.get_slot_data()
|
||||
slot_data["version"] = 4
|
||||
|
||||
if SC2Campaign.HOTS not in enabled_campaigns:
|
||||
if (SC2Campaign.HOTS not in enabled_campaigns
|
||||
or SC2Race.ZERG.get_title() not in self.options.selected_races.value
|
||||
):
|
||||
slot_data["kerrigan_presence"] = KerriganPresence.option_not_present
|
||||
|
||||
if self.options.mission_order_scouting != MissionOrderScouting.option_none:
|
||||
@@ -259,8 +294,8 @@ class SC2World(World):
|
||||
assert self.logic is not None
|
||||
self.logic.total_mission_count = self.custom_mission_order.get_mission_count()
|
||||
if (
|
||||
self.options.generic_upgrade_missions > 0
|
||||
and self.options.required_tactics != RequiredTactics.option_no_logic
|
||||
self.options.generic_upgrade_missions > 0
|
||||
and self.options.required_tactics != RequiredTactics.option_no_logic
|
||||
):
|
||||
# Attempt to resolve a situation when the option is too high for the mission order rolled
|
||||
weapon_armor_item_names = [
|
||||
@@ -277,8 +312,8 @@ class SC2World(World):
|
||||
|
||||
self._fill_needed_items(state_with_kerrigan_levels, weapon_armor_item_names, WEAPON_ARMOR_UPGRADE_MAX_LEVEL)
|
||||
if (
|
||||
self.options.kerrigan_levels_per_mission_completed > 0
|
||||
and self.options.required_tactics != RequiredTactics.option_no_logic
|
||||
self.options.kerrigan_levels_per_mission_completed > 0
|
||||
and self.options.required_tactics != RequiredTactics.option_no_logic
|
||||
):
|
||||
# Attempt to solve being locked by Kerrigan level requirements
|
||||
self._fill_needed_items(lambda: self.multiworld.get_all_state(False), [item_names.KERRIGAN_LEVELS_1], 70)
|
||||
@@ -518,9 +553,9 @@ def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterIte
|
||||
item.flags |= ItemFilterFlags.FilterExcluded
|
||||
if (not protoss_build_missions
|
||||
and item.data.type in (
|
||||
item_tables.ProtossItemType.Unit,
|
||||
item_tables.ProtossItemType.Unit_2,
|
||||
item_tables.ProtossItemType.Building,
|
||||
item_tables.ProtossItemType.Unit,
|
||||
item_tables.ProtossItemType.Unit_2,
|
||||
item_tables.ProtossItemType.Building,
|
||||
)
|
||||
):
|
||||
# Note(mm): This doesn't exclude things like automated assimilators or warp gate improvements
|
||||
@@ -528,9 +563,9 @@ def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterIte
|
||||
if (SC2Mission.TEMPLAR_S_RETURN not in missions
|
||||
or world.options.grant_story_tech.value == GrantStoryTech.option_grant
|
||||
or item.name not in (
|
||||
item_names.IMMORTAL, item_names.ANNIHILATOR,
|
||||
item_names.COLOSSUS, item_names.VANGUARD, item_names.REAVER, item_names.DARK_TEMPLAR,
|
||||
item_names.SENTRY, item_names.HIGH_TEMPLAR,
|
||||
item_names.IMMORTAL, item_names.ANNIHILATOR,
|
||||
item_names.COLOSSUS, item_names.VANGUARD, item_names.REAVER, item_names.DARK_TEMPLAR,
|
||||
item_names.SENTRY, item_names.HIGH_TEMPLAR,
|
||||
)
|
||||
):
|
||||
item.flags |= ItemFilterFlags.FilterExcluded
|
||||
@@ -565,15 +600,16 @@ def flag_mission_based_item_excludes(world: SC2World, item_list: List[FilterItem
|
||||
mission for mission in missions
|
||||
if MissionFlag.Nova in mission.flags
|
||||
or (
|
||||
world.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco
|
||||
and MissionFlag.WoLNova in mission.flags
|
||||
world.options.nova_ghost_of_a_chance_variant == NovaGhostOfAChanceVariant.option_nco
|
||||
and MissionFlag.WoLNova in mission.flags
|
||||
)
|
||||
]
|
||||
|
||||
kerrigan_is_present = (
|
||||
len(kerrigan_missions) > 0
|
||||
and world.options.kerrigan_presence in kerrigan_unit_available
|
||||
and SC2Campaign.HOTS in get_enabled_campaigns(world) # TODO: Kerrigan available all Zerg/Everywhere
|
||||
len(kerrigan_missions) > 0
|
||||
and world.options.kerrigan_presence in kerrigan_unit_available
|
||||
and SC2Campaign.HOTS in get_enabled_campaigns(world) # TODO: Kerrigan available all Zerg/Everywhere
|
||||
and SC2Race.ZERG.get_title() in world.options.selected_races.value
|
||||
)
|
||||
|
||||
# TvX build missions -- check flags
|
||||
|
||||
@@ -600,7 +600,7 @@ item_name_groups[ItemGroupNames.KERRIGAN_LOGIC_ACTIVE_ABILITIES] = kerrigan_logi
|
||||
item_name for item_name in kerrigan_active_abilities if item_name != item_names.KERRIGAN_ASSIMILATION_AURA
|
||||
]
|
||||
item_name_groups[ItemGroupNames.KERRIGAN_TIER_1] = kerrigan_tier_1 = [
|
||||
item_names.KERRIGAN_CRUSHING_GRIP, item_names.KERRIGAN_HEROIC_FORTITUDE, item_names.KERRIGAN_LEAPING_STRIKE
|
||||
item_names.KERRIGAN_KINETIC_BLAST, item_names.KERRIGAN_HEROIC_FORTITUDE, item_names.KERRIGAN_LEAPING_STRIKE
|
||||
]
|
||||
item_name_groups[ItemGroupNames.KERRIGAN_TIER_2] = kerrigan_tier_2= [
|
||||
item_names.KERRIGAN_CRUSHING_GRIP, item_names.KERRIGAN_CHAIN_REACTION, item_names.KERRIGAN_PSIONIC_SHIFT
|
||||
|
||||
@@ -1576,7 +1576,7 @@ def get_option_value(world: Union['SC2World', None], name: str) -> int:
|
||||
|
||||
|
||||
def get_enabled_races(world: Optional['SC2World']) -> Set[SC2Race]:
|
||||
race_names = world.options.selected_races.value if world and len(world.options.selected_races.value) > 0 else SelectedRaces.valid_keys
|
||||
race_names = world.options.selected_races.value if world else SelectedRaces.default
|
||||
return {race for race in SC2Race if race.get_title() in race_names}
|
||||
|
||||
|
||||
@@ -1584,16 +1584,7 @@ def get_enabled_campaigns(world: Optional['SC2World']) -> Set[SC2Campaign]:
|
||||
if world is None:
|
||||
return {campaign for campaign in SC2Campaign if campaign.campaign_name in EnabledCampaigns.default}
|
||||
campaign_names = world.options.enabled_campaigns
|
||||
campaigns = {campaign for campaign in SC2Campaign if campaign.campaign_name in campaign_names}
|
||||
if (world.options.mission_order.value == MissionOrder.option_vanilla
|
||||
and get_enabled_races(world) != {SC2Race.TERRAN, SC2Race.ZERG, SC2Race.PROTOSS}
|
||||
and SC2Campaign.EPILOGUE in campaigns
|
||||
):
|
||||
campaigns.remove(SC2Campaign.EPILOGUE)
|
||||
if len(campaigns) == 0:
|
||||
# Everything is disabled, roll as everything enabled
|
||||
return {campaign for campaign in SC2Campaign if campaign != SC2Campaign.GLOBAL}
|
||||
return campaigns
|
||||
return {campaign for campaign in SC2Campaign if campaign.campaign_name in campaign_names}
|
||||
|
||||
|
||||
def get_disabled_campaigns(world: 'SC2World') -> Set[SC2Campaign]:
|
||||
@@ -1606,8 +1597,8 @@ def get_disabled_campaigns(world: 'SC2World') -> Set[SC2Campaign]:
|
||||
|
||||
def get_disabled_flags(world: 'SC2World') -> MissionFlag:
|
||||
excluded = (
|
||||
(MissionFlag.Terran | MissionFlag.Zerg | MissionFlag.Protoss)
|
||||
^ functools.reduce(lambda a, b: a | b, [race.get_mission_flag() for race in get_enabled_races(world)])
|
||||
(MissionFlag.Terran | MissionFlag.Zerg | MissionFlag.Protoss)
|
||||
^ functools.reduce(lambda a, b: a | b, [race.get_mission_flag() for race in get_enabled_races(world)])
|
||||
)
|
||||
# filter out no-build missions
|
||||
if not world.options.shuffle_no_build.value:
|
||||
|
||||
@@ -1142,7 +1142,10 @@ class SC2Logic:
|
||||
return levels >= target
|
||||
|
||||
def basic_kerrigan(self, state: CollectionState, story_tech_available=True) -> bool:
|
||||
if story_tech_available and self.grant_story_tech == GrantStoryTech.option_grant:
|
||||
if story_tech_available and (
|
||||
self.grant_story_tech == GrantStoryTech.option_grant
|
||||
or not self.kerrigan_unit_available
|
||||
):
|
||||
return True
|
||||
# One active ability that can be used to defeat enemies directly
|
||||
if not state.has_any(
|
||||
@@ -1166,7 +1169,10 @@ class SC2Logic:
|
||||
return False
|
||||
|
||||
def two_kerrigan_actives(self, state: CollectionState, story_tech_available=True) -> bool:
|
||||
if story_tech_available and self.grant_story_tech == GrantStoryTech.option_grant:
|
||||
if story_tech_available and (
|
||||
self.grant_story_tech == GrantStoryTech.option_grant
|
||||
or not self.kerrigan_unit_available
|
||||
):
|
||||
return True
|
||||
return state.count_from_list(item_groups.kerrigan_logic_active_abilities, self.player) >= 2
|
||||
|
||||
@@ -2361,6 +2367,7 @@ class SC2Logic:
|
||||
# Note(mm): This check isn't necessary as self.kerrigan_levels cover it,
|
||||
# and it's not fully desirable in future when we support non-grant story tech + kerriganless.
|
||||
# or not self.kerrigan_presence
|
||||
or not self.kerrigan_unit_available
|
||||
or state.has_any((
|
||||
# Cases tested by Snarky
|
||||
item_names.KERRIGAN_KINETIC_BLAST,
|
||||
@@ -2399,22 +2406,23 @@ class SC2Logic:
|
||||
def supreme_requirement(self, state: CollectionState) -> bool:
|
||||
return (
|
||||
self.grant_story_tech == GrantStoryTech.option_grant
|
||||
or not self.kerrigan_unit_available or (self.grant_story_tech == GrantStoryTech.option_allow_substitutes
|
||||
and state.has_any((
|
||||
item_names.KERRIGAN_LEAPING_STRIKE,
|
||||
item_names.OVERLORD_VENTRAL_SACS,
|
||||
item_names.YGGDRASIL,
|
||||
item_names.MUTALISK_CORRUPTOR_VIPER_ASPECT,
|
||||
item_names.NYDUS_WORM,
|
||||
item_names.BULLFROG,
|
||||
), self.player)
|
||||
and state.has_any((
|
||||
item_names.KERRIGAN_MEND,
|
||||
item_names.SWARM_QUEEN,
|
||||
item_names.INFESTED_MEDICS,
|
||||
), self.player)
|
||||
and self.kerrigan_levels(state, 35)
|
||||
)
|
||||
or not self.kerrigan_unit_available
|
||||
or (self.grant_story_tech == GrantStoryTech.option_allow_substitutes
|
||||
and state.has_any((
|
||||
item_names.KERRIGAN_LEAPING_STRIKE,
|
||||
item_names.OVERLORD_VENTRAL_SACS,
|
||||
item_names.YGGDRASIL,
|
||||
item_names.MUTALISK_CORRUPTOR_VIPER_ASPECT,
|
||||
item_names.NYDUS_WORM,
|
||||
item_names.BULLFROG,
|
||||
), self.player)
|
||||
and state.has_any((
|
||||
item_names.KERRIGAN_MEND,
|
||||
item_names.SWARM_QUEEN,
|
||||
item_names.INFESTED_MEDICS,
|
||||
), self.player)
|
||||
and self.kerrigan_levels(state, 35)
|
||||
)
|
||||
or (state.has_all((item_names.KERRIGAN_LEAPING_STRIKE, item_names.KERRIGAN_MEND), self.player) and self.kerrigan_levels(state, 35))
|
||||
)
|
||||
|
||||
@@ -2502,15 +2510,12 @@ class SC2Logic:
|
||||
self.grant_story_tech == GrantStoryTech.option_grant
|
||||
or not self.kerrigan_unit_available
|
||||
or (
|
||||
state.has_any(
|
||||
(
|
||||
item_names.KERRIGAN_KINETIC_BLAST,
|
||||
item_names.KERRIGAN_SPAWN_BANELINGS,
|
||||
item_names.KERRIGAN_LEAPING_STRIKE,
|
||||
item_names.KERRIGAN_SPAWN_LEVIATHAN,
|
||||
),
|
||||
self.player,
|
||||
)
|
||||
state.has_any((
|
||||
item_names.KERRIGAN_KINETIC_BLAST,
|
||||
item_names.KERRIGAN_SPAWN_BANELINGS,
|
||||
item_names.KERRIGAN_LEAPING_STRIKE,
|
||||
item_names.KERRIGAN_SPAWN_LEVIATHAN,
|
||||
), self.player)
|
||||
and self.basic_kerrigan(state)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -271,9 +271,19 @@ class TestSupportedUseCases(Sc2SetupTestBase):
|
||||
world_regions.remove('Menu')
|
||||
|
||||
for region in world_regions:
|
||||
self.assertNotIn(mission_tables.lookup_name_to_mission[region].campaign,
|
||||
([mission_tables.SC2Campaign.EPILOGUE]),
|
||||
f"{region} is an epilogue mission!")
|
||||
self.assertNotIn(
|
||||
mission_tables.lookup_name_to_mission[region].campaign,
|
||||
([mission_tables.SC2Campaign.EPILOGUE]),
|
||||
f"{region} is an epilogue mission!"
|
||||
)
|
||||
|
||||
def test_excluding_all_factions_and_campaigns_still_generates(self) -> None:
|
||||
world_options = {
|
||||
'selected_races': set(),
|
||||
'enabled_campaigns': set(),
|
||||
}
|
||||
# asserting no exception is thrown
|
||||
self.generate_world(world_options)
|
||||
|
||||
def test_race_swap_pick_one_has_correct_length_and_includes_swaps(self) -> None:
|
||||
world_options = {
|
||||
|
||||
Reference in New Issue
Block a user