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:
Phaneros
2026-01-19 11:11:31 -08:00
committed by GitHub
parent b8311a62e7
commit 9f71fe707f
5 changed files with 115 additions and 73 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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)
)
)

View File

@@ -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 = {