Skip to content

Commit d834ece

Browse files
SC2: Fix bugs and issues around excluded/unexcluded (#5644)
1 parent f3000a8 commit d834ece

File tree

9 files changed

+195
-141
lines changed

9 files changed

+195
-141
lines changed

worlds/sc2/__init__.py

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -374,22 +374,32 @@ def create_and_flag_explicit_item_locks_and_excludes(world: SC2World) -> List[Fi
374374
Handles `excluded_items`, `locked_items`, and `start_inventory`
375375
Returns a list of all possible non-filler items that can be added, with an accompanying flags bitfield.
376376
"""
377-
excluded_items = world.options.excluded_items
378-
unexcluded_items = world.options.unexcluded_items
379-
locked_items = world.options.locked_items
380-
start_inventory = world.options.start_inventory
377+
excluded_items: dict[str, int] = world.options.excluded_items.value
378+
unexcluded_items: dict[str, int] = world.options.unexcluded_items.value
379+
locked_items: dict[str, int] = world.options.locked_items.value
380+
start_inventory: dict[str, int] = world.options.start_inventory.value
381381
key_items = world.custom_mission_order.get_items_to_lock()
382382

383-
def resolve_count(count: Optional[int], max_count: int) -> int:
384-
if count == 0:
383+
def resolve_exclude(count: int, max_count: int) -> int:
384+
if count < 0:
385385
return max_count
386-
if count is None:
387-
return 0
388-
if max_count == 0:
389-
return count
390-
return min(count, max_count)
386+
return count
387+
388+
def resolve_count(count: int, max_count: int, negative_value: int | None = None) -> int:
389+
"""
390+
Handles `count` being out of range.
391+
* If `count > max_count`, returns `max_count`.
392+
* If `count < 0`, returns `negative_value` (returns `max_count` if `negative_value` is unspecified)
393+
"""
394+
if count < 0:
395+
if negative_value is None:
396+
return max_count
397+
return negative_value
398+
if max_count and count > max_count:
399+
return max_count
400+
return count
391401

392-
auto_excludes = {item_name: 1 for item_name in item_groups.legacy_items}
402+
auto_excludes = Counter({item_name: 1 for item_name in item_groups.legacy_items})
393403
if world.options.exclude_overpowered_items.value == ExcludeOverpoweredItems.option_true:
394404
for item_name in item_groups.overpowered_items:
395405
auto_excludes[item_name] = 1
@@ -402,28 +412,29 @@ def resolve_count(count: Optional[int], max_count: int) -> int:
402412
elif item_name in item_groups.nova_equipment:
403413
continue
404414
else:
405-
auto_excludes[item_name] = 0
415+
auto_excludes[item_name] = item_data.quantity
406416

407417

408418
result: List[FilterItem] = []
409419
for item_name, item_data in item_tables.item_table.items():
410420
max_count = item_data.quantity
411-
auto_excluded_count = auto_excludes.get(item_name)
421+
auto_excluded_count = auto_excludes.get(item_name, 0)
412422
excluded_count = excluded_items.get(item_name, auto_excluded_count)
413-
unexcluded_count = unexcluded_items.get(item_name)
414-
locked_count = locked_items.get(item_name)
415-
start_count: Optional[int] = start_inventory.get(item_name)
423+
unexcluded_count = unexcluded_items.get(item_name, 0)
424+
locked_count = locked_items.get(item_name, 0)
425+
start_count = start_inventory.get(item_name, 0)
416426
key_count = key_items.get(item_name, 0)
417-
# specifying 0 in the yaml means exclude / lock all
418-
# start_inventory doesn't allow specifying 0
419-
# not specifying means don't exclude/lock/start
420-
excluded_count = resolve_count(excluded_count, max_count)
421-
unexcluded_count = resolve_count(unexcluded_count, max_count)
427+
# Specifying a negative number in the yaml means exclude / lock / start all.
428+
# In the case of excluded/unexcluded, resolve negatives to max_count before subtracting them,
429+
# and after subtraction resolve negatives to just 0 (when unexcluded > excluded).
430+
excluded_count = resolve_count(
431+
resolve_exclude(excluded_count, max_count) - resolve_exclude(unexcluded_count, max_count),
432+
max_count,
433+
negative_value=0
434+
)
422435
locked_count = resolve_count(locked_count, max_count)
423436
start_count = resolve_count(start_count, max_count)
424437

425-
excluded_count = max(0, excluded_count - unexcluded_count)
426-
427438
# Priority: start_inventory >> locked_items >> excluded_items >> unspecified
428439
if max_count == 0:
429440
if excluded_count:
@@ -486,8 +497,9 @@ def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterIte
486497
item.flags |= ItemFilterFlags.FilterExcluded
487498
continue
488499
if not zerg_missions and item.data.race == SC2Race.ZERG:
489-
if item.data.type != item_tables.ZergItemType.Ability \
490-
and item.data.type != ZergItemType.Level:
500+
if (item.data.type != item_tables.ZergItemType.Ability
501+
and item.data.type != ZergItemType.Level
502+
):
491503
item.flags |= ItemFilterFlags.FilterExcluded
492504
continue
493505
if not protoss_missions and item.data.race == SC2Race.PROTOSS:
@@ -641,7 +653,7 @@ def flag_mission_based_item_excludes(world: SC2World, item_list: List[FilterItem
641653
item.flags |= ItemFilterFlags.FilterExcluded
642654

643655
# Remove Spear of Adun passives
644-
if item.name in item_tables.spear_of_adun_castable_passives and not soa_passive_presence:
656+
if item.name in item_groups.spear_of_adun_passives and not soa_passive_presence:
645657
item.flags |= ItemFilterFlags.FilterExcluded
646658

647659
# Remove matchup-specific items if you don't play that matchup

worlds/sc2/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
SpearOfAdunPassivesPresentInNoBuild, EnableVoidTrade, VoidTradeAgeLimit, void_trade_age_limits_ms, VoidTradeWorkers,
4141
DifficultyDamageModifier, MissionOrderScouting, GenericUpgradeResearchSpeedup, MercenaryHighlanders, WarCouncilNerfs,
4242
is_mission_in_soa_presence,
43+
upgrade_included_names,
4344
)
4445
from .mission_order.slot_data import CampaignSlotData, LayoutSlotData, MissionSlotData, MissionOrderObjectSlotData
4546
from .mission_order.entry_rules import SubRuleRuleData, CountMissionsRuleData, MissionEntryRules
@@ -71,10 +72,12 @@
7172
)
7273

7374
import colorama
74-
from .options import Option, upgrade_included_names
7575
from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart, add_json_item, add_json_location, add_json_text, JSONTypes
7676
from MultiServer import mark_raw
7777

78+
if typing.TYPE_CHECKING:
79+
from Options import Option
80+
7881
pool = concurrent.futures.ThreadPoolExecutor(1)
7982
loop = asyncio.get_event_loop_policy().new_event_loop()
8083
nest_asyncio.apply(loop)

worlds/sc2/item/item_groups.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ class ItemGroupNames:
167167
LOTV_UNITS = "LotV Units"
168168
LOTV_ITEMS = "LotV Items"
169169
LOTV_GLOBAL_UPGRADES = "LotV Global Upgrades"
170+
SOA_PASSIVES = "SOA Passive Abilities"
170171
SOA_ITEMS = "SOA"
171172
PROTOSS_GLOBAL_UPGRADES = "Protoss Global Upgrades"
172173
PROTOSS_BUILDINGS = "Protoss Buildings"
@@ -777,11 +778,21 @@ def get_all_group_names(cls) -> typing.Set[str]:
777778
item_names.MIRAGE, item_names.DAWNBRINGER, item_names.TRIREME, item_names.TEMPEST,
778779
item_names.CALADRIUS,
779780
]
780-
item_name_groups[ItemGroupNames.SOA_ITEMS] = soa_items = [
781+
item_name_groups[ItemGroupNames.SOA_PASSIVES] = spear_of_adun_passives = [
782+
item_names.RECONSTRUCTION_BEAM,
783+
item_names.OVERWATCH,
784+
item_names.GUARDIAN_SHELL,
785+
]
786+
spear_of_adun_actives = [
781787
*[item_name for item_name, item_data in item_tables.item_table.items() if item_data.type == item_tables.ProtossItemType.Spear_Of_Adun],
782788
item_names.SOA_PROGRESSIVE_PROXY_PYLON,
783789
]
784-
lotv_soa_items = [item_name for item_name in soa_items if item_name != item_names.SOA_PYLON_OVERCHARGE]
790+
item_name_groups[ItemGroupNames.SOA_ITEMS] = soa_items = spear_of_adun_actives + spear_of_adun_passives
791+
lotv_soa_items = [
792+
item_name
793+
for item_name in soa_items
794+
if item_name not in (item_names.SOA_PYLON_OVERCHARGE, item_names.OVERWATCH)
795+
]
785796
item_name_groups[ItemGroupNames.PROTOSS_GLOBAL_UPGRADES] = [
786797
item_name for item_name, item_data in item_tables.item_table.items() if item_data.type == item_tables.ProtossItemType.Solarite_Core
787798
]

worlds/sc2/item/item_tables.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2293,12 +2293,6 @@ def get_item_table():
22932293
item_names.SOA_SOLAR_BOMBARDMENT
22942294
}
22952295

2296-
spear_of_adun_castable_passives = {
2297-
item_names.RECONSTRUCTION_BEAM,
2298-
item_names.OVERWATCH,
2299-
item_names.GUARDIAN_SHELL,
2300-
}
2301-
23022296
nova_equipment = {
23032297
*[item_name for item_name, item_data in get_full_item_list().items()
23042298
if item_data.type == TerranItemType.Nova_Gear],

worlds/sc2/options.py

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55

66
from Options import (
77
Choice, Toggle, DefaultOnToggle, OptionSet, Range,
8-
PerGameCommonOptions, Option, VerifyKeys, StartInventory,
8+
PerGameCommonOptions, VerifyKeys, StartInventory,
99
is_iterable_except_str, OptionGroup, Visibility, ItemDict,
10-
Accessibility, ProgressionBalancing
10+
OptionCounter,
1111
)
1212
from Utils import get_fuzzy_results
1313
from BaseClasses import PlandoOptions
14-
from .item import item_names, item_tables
15-
from .item.item_groups import kerrigan_active_abilities, kerrigan_passives, nova_weapons, nova_gadgets
14+
from .item import item_names, item_tables, item_groups
1615
from .mission_tables import (
1716
SC2Campaign, SC2Mission, lookup_name_to_mission, MissionPools, get_missions_with_any_flags_in_list,
1817
campaign_mission_table, SC2Race, MissionFlag
@@ -700,7 +699,7 @@ class KerriganMaxActiveAbilities(Range):
700699
"""
701700
display_name = "Kerrigan Maximum Active Abilities"
702701
range_start = 0
703-
range_end = len(kerrigan_active_abilities)
702+
range_end = len(item_groups.kerrigan_active_abilities)
704703
default = range_end
705704

706705

@@ -711,7 +710,7 @@ class KerriganMaxPassiveAbilities(Range):
711710
"""
712711
display_name = "Kerrigan Maximum Passive Abilities"
713712
range_start = 0
714-
range_end = len(kerrigan_passives)
713+
range_end = len(item_groups.kerrigan_passives)
715714
default = range_end
716715

717716

@@ -829,7 +828,7 @@ class SpearOfAdunMaxAutocastAbilities(Range):
829828
"""
830829
display_name = "Spear of Adun Maximum Passive Abilities"
831830
range_start = 0
832-
range_end = sum(item.quantity for item_name, item in item_tables.get_full_item_list().items() if item_name in item_tables.spear_of_adun_castable_passives)
831+
range_end = sum(item_tables.item_table[item_name].quantity for item_name in item_groups.spear_of_adun_passives)
833832
default = range_end
834833

835834

@@ -883,7 +882,7 @@ class NovaMaxWeapons(Range):
883882
"""
884883
display_name = "Nova Maximum Weapons"
885884
range_start = 0
886-
range_end = len(nova_weapons)
885+
range_end = len(item_groups.nova_weapons)
887886
default = range_end
888887

889888

@@ -897,7 +896,7 @@ class NovaMaxGadgets(Range):
897896
"""
898897
display_name = "Nova Maximum Gadgets"
899898
range_start = 0
900-
range_end = len(nova_gadgets)
899+
range_end = len(item_groups.nova_gadgets)
901900
default = range_end
902901

903902

@@ -932,33 +931,48 @@ class TakeOverAIAllies(Toggle):
932931
display_name = "Take Over AI Allies"
933932

934933

935-
class Sc2ItemDict(Option[Dict[str, int]], VerifyKeys, Mapping[str, int]):
936-
"""A branch of ItemDict that supports item counts of 0"""
934+
class Sc2ItemDict(OptionCounter, VerifyKeys, Mapping[str, int]):
935+
"""A branch of ItemDict that supports negative item counts"""
937936
default = {}
938937
supports_weighting = False
939938
verify_item_name = True
940939
# convert_name_groups = True
941940
display_name = 'Unnamed dictionary'
942-
minimum_value: int = 0
941+
# Note(phaneros): Limiting minimum to -1 means that if two triggers add -1 to the same item,
942+
# the validation fails. So give trigger people space to stack a bunch of triggers.
943+
min: int = -1000
944+
max: int = 1000
945+
valid_keys = set(item_tables.item_table) | set(item_groups.item_name_groups)
943946

944-
def __init__(self, value: Dict[str, int]):
947+
def __init__(self, value: dict[str, int]):
945948
self.value = {key: val for key, val in value.items()}
946949

947950
@classmethod
948-
def from_any(cls, data: Union[List[str], Dict[str, int]]) -> 'Sc2ItemDict':
951+
def from_any(cls, data: list[str] | dict[str, int]) -> 'Sc2ItemDict':
949952
if isinstance(data, list):
950-
# This is a little default that gets us backwards compatibility with lists.
951-
# It doesn't play nice with trigger merging dicts and lists together, though, so best not to advertise it overmuch.
952-
data = {item: 0 for item in data}
953+
raise ValueError(
954+
f"{cls.display_name}: Cannot convert from list. "
955+
f"Use dict syntax (no dashes, 'value: number' synax)."
956+
)
953957
if isinstance(data, dict):
954958
for key, value in data.items():
955959
if not isinstance(value, int):
956-
raise ValueError(f"Invalid type in '{cls.display_name}': element '{key}' maps to '{value}', expected an integer")
957-
if value < cls.minimum_value:
958-
raise ValueError(f"Invalid value for '{cls.display_name}': element '{key}' maps to {value}, which is less than the minimum ({cls.minimum_value})")
960+
raise ValueError(
961+
f"Invalid type in '{cls.display_name}': "
962+
f"element '{key}' maps to '{value}', expected an integer"
963+
)
964+
if value < cls.min:
965+
raise ValueError(
966+
f"Invalid value for '{cls.display_name}': "
967+
f"element '{key}' maps to {value}, which is less than the minimum ({cls.min})"
968+
)
969+
if value > cls.max:
970+
raise ValueError(f"Invalid value for '{cls.display_name}': "
971+
f"element '{key}' maps to {value}, which is greater than the maximum ({cls.max})"
972+
)
959973
return cls(data)
960974
else:
961-
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
975+
raise NotImplementedError(f"{cls.display_name}: Cannot convert from non-dictionary, got {type(data)}")
962976

963977
def verify(self, world: Type['World'], player_name: str, plando_options: PlandoOptions) -> None:
964978
"""Overridden version of function from Options.VerifyKeys for a better error message"""
@@ -974,15 +988,16 @@ def verify(self, world: Type['World'], player_name: str, plando_options: PlandoO
974988
self.value = new_value
975989
for item_name in self.value:
976990
if item_name not in world.item_names:
977-
from .item import item_groups
978991
picks = get_fuzzy_results(
979992
item_name,
980993
list(world.item_names) + list(item_groups.ItemGroupNames.get_all_group_names()),
981994
limit=1,
982995
)
983-
raise Exception(f"Item {item_name} from option {self} "
984-
f"is not a valid item name from {world.game}. "
985-
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
996+
raise Exception(
997+
f"Item {item_name} from option {self} "
998+
f"is not a valid item name from {world.game}. "
999+
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)"
1000+
)
9861001

9871002
def get_option_name(self, value):
9881003
return ", ".join(f"{key}: {v}" for key, v in value.items())
@@ -998,25 +1013,25 @@ def __len__(self) -> int:
9981013

9991014

10001015
class Sc2StartInventory(Sc2ItemDict):
1001-
"""Start with these items."""
1016+
"""Start with these items. Use an amount of -1 to start with all copies of an item."""
10021017
display_name = StartInventory.display_name
10031018

10041019

10051020
class LockedItems(Sc2ItemDict):
10061021
"""Guarantees that these items will be unlockable, in the amount specified.
1007-
Specify an amount of 0 to lock all copies of an item."""
1022+
Specify an amount of -1 to lock all copies of an item."""
10081023
display_name = "Locked Items"
10091024

10101025

10111026
class ExcludedItems(Sc2ItemDict):
10121027
"""Guarantees that these items will not be unlockable, in the amount specified.
1013-
Specify an amount of 0 to exclude all copies of an item."""
1028+
Specify an amount of -1 to exclude all copies of an item."""
10141029
display_name = "Excluded Items"
10151030

10161031

10171032
class UnexcludedItems(Sc2ItemDict):
10181033
"""Undoes an item exclusion; useful for whitelisting or fine-tuning a category.
1019-
Specify an amount of 0 to unexclude all copies of an item."""
1034+
Specify an amount of -1 to unexclude all copies of an item."""
10201035
display_name = "Unexcluded Items"
10211036

10221037

worlds/sc2/pool_filter.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33

44
from BaseClasses import Location, ItemClassification
55
from .item import StarcraftItem, ItemFilterFlags, item_names, item_parents, item_groups
6-
from .item.item_tables import item_table, TerranItemType, ZergItemType, spear_of_adun_calldowns, \
7-
spear_of_adun_castable_passives
6+
from .item.item_tables import item_table, TerranItemType, ZergItemType, spear_of_adun_calldowns
87
from .options import RequiredTactics
98

109
if TYPE_CHECKING:
@@ -272,7 +271,7 @@ def request_minimum_items(group: List[StarcraftItem], requested_minimum) -> None
272271
self.world.random.shuffle(spear_of_adun_actives)
273272
cull_items_over_maximum(spear_of_adun_actives, self.world.options.spear_of_adun_max_active_abilities.value)
274273

275-
spear_of_adun_autocasts = [item for item in inventory if item.name in spear_of_adun_castable_passives]
274+
spear_of_adun_autocasts = [item for item in inventory if item.name in item_groups.spear_of_adun_passives]
276275
self.world.random.shuffle(spear_of_adun_autocasts)
277276
cull_items_over_maximum(spear_of_adun_autocasts, self.world.options.spear_of_adun_max_passive_abilities.value)
278277

0 commit comments

Comments
 (0)