From cf1ae860fbcb7a7a3af71c9aa22812aa8ade0071 Mon Sep 17 00:00:00 2001 From: Magnemania Date: Thu, 13 Oct 2022 07:50:39 -0400 Subject: [PATCH] SC2: Required Tactics and Unit Upgrade options, better connected item handling --- worlds/sc2wol/Items.py | 122 ++++++++++++++++------------ worlds/sc2wol/Locations.py | 17 ++-- worlds/sc2wol/LogicMixin.py | 24 ++++-- worlds/sc2wol/MissionTables.py | 20 ++++- worlds/sc2wol/Options.py | 58 +++++++++++--- worlds/sc2wol/PoolFilter.py | 141 ++++++++++++++++++++------------- worlds/sc2wol/__init__.py | 22 ++--- 7 files changed, 259 insertions(+), 145 deletions(-) diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index 18921a769d..836c8f6a85 100644 --- a/worlds/sc2wol/Items.py +++ b/worlds/sc2wol/Items.py @@ -1,5 +1,7 @@ -from BaseClasses import Item, ItemClassification +from BaseClasses import Item, ItemClassification, MultiWorld import typing + +from .Options import get_option_value from .MissionTables import vanilla_mission_req_table @@ -9,6 +11,7 @@ class ItemData(typing.NamedTuple): number: typing.Optional[int] classification: ItemClassification = ItemClassification.useful quantity: int = 1 + parent_item: str = None class StarcraftWoLItem(Item): @@ -48,51 +51,51 @@ item_table = { "Progressive Ship Weapon": ItemData(105 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 8, quantity=3), "Progressive Ship Armor": ItemData(106 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 10, quantity=3), - "Projectile Accelerator (Bunker)": ItemData(200 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 0), - "Neosteel Bunker (Bunker)": ItemData(201 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 1), - "Titanium Housing (Missile Turret)": ItemData(202 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 2, classification=ItemClassification.filler), - "Hellstorm Batteries (Missile Turret)": ItemData(203 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 3), - "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4), - "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5), - "Fire-Suppression System (Building)": ItemData(206 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 6, classification=ItemClassification.filler), - "Orbital Command (Building)": ItemData(207 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 7), - "Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8), - "Combat Shield (Marine)": ItemData(209 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 9, classification=ItemClassification.progression), - "Advanced Medic Facilities (Medic)": ItemData(210 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 10, classification=ItemClassification.progression), - "Stabilizer Medpacks (Medic)": ItemData(211 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 11, classification=ItemClassification.progression), - "Incinerator Gauntlets (Firebat)": ItemData(212 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 12, classification=ItemClassification.filler), - "Juggernaut Plating (Firebat)": ItemData(213 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 13), - "Concussive Shells (Marauder)": ItemData(214 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 14), - "Kinetic Foam (Marauder)": ItemData(215 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 15), - "U-238 Rounds (Reaper)": ItemData(216 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 16), - "G-4 Clusterbomb (Reaper)": ItemData(217 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 17, classification=ItemClassification.progression), + "Projectile Accelerator (Bunker)": ItemData(200 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 0, parent_item="Bunker"), + "Neosteel Bunker (Bunker)": ItemData(201 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 1, parent_item="Bunker"), + "Titanium Housing (Missile Turret)": ItemData(202 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 2, classification=ItemClassification.filler, parent_item="Missile Turret"), + "Hellstorm Batteries (Missile Turret)": ItemData(203 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 3, parent_item="Missile Turret"), + "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4, parent_item="SCV"), + "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5, parent_item="SCV"), + "Fire-Suppression System (Building)": ItemData(206 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 6, classification=ItemClassification.filler, parent_item="Building"), + "Orbital Command (Building)": ItemData(207 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 7, parent_item="Building"), + "Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8, parent_item="Marine"), + "Combat Shield (Marine)": ItemData(209 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 9, classification=ItemClassification.progression, parent_item="Marine"), + "Advanced Medic Facilities (Medic)": ItemData(210 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 10, classification=ItemClassification.progression, parent_item="Medic"), + "Stabilizer Medpacks (Medic)": ItemData(211 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 11, classification=ItemClassification.progression, parent_item="Medic"), + "Incinerator Gauntlets (Firebat)": ItemData(212 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 12, classification=ItemClassification.filler, parent_item="Firebat"), + "Juggernaut Plating (Firebat)": ItemData(213 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 13, parent_item="Firebat"), + "Concussive Shells (Marauder)": ItemData(214 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 14, parent_item="Marauder"), + "Kinetic Foam (Marauder)": ItemData(215 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 15, parent_item="Marauder"), + "U-238 Rounds (Reaper)": ItemData(216 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 16, parent_item="Reaper"), + "G-4 Clusterbomb (Reaper)": ItemData(217 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 17, classification=ItemClassification.progression, parent_item="Reaper"), - "Twin-Linked Flamethrower (Hellion)": ItemData(300 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 0, classification=ItemClassification.filler), - "Thermite Filaments (Hellion)": ItemData(301 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 1), - "Cerberus Mine (Vulture)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler), - "Replenishable Magazine (Vulture)": ItemData(303 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 3, classification=ItemClassification.filler), - "Multi-Lock Weapons System (Goliath)": ItemData(304 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 4), - "Ares-Class Targeting System (Goliath)": ItemData(305 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 5), - "Tri-Lithium Power Cell (Diamondback)": ItemData(306 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 6, classification=ItemClassification.filler), - "Shaped Hull (Diamondback)": ItemData(307 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 7, classification=ItemClassification.filler), - "Maelstrom Rounds (Siege Tank)": ItemData(308 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 8), - "Shaped Blast (Siege Tank)": ItemData(309 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 9), - "Rapid Deployment Tube (Medivac)": ItemData(310 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 10, classification=ItemClassification.filler), - "Advanced Healing AI (Medivac)": ItemData(311 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 11, classification=ItemClassification.filler), - "Tomahawk Power Cells (Wraith)": ItemData(312 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 12, classification=ItemClassification.filler), - "Displacement Field (Wraith)": ItemData(313 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 13, classification=ItemClassification.filler), - "Ripwave Missiles (Viking)": ItemData(314 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 14), - "Phobos-Class Weapons System (Viking)": ItemData(315 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 15), - "Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 16, classification=ItemClassification.filler), - "Shockwave Missile Battery (Banshee)": ItemData(317 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 17), - "Missile Pods (Battlecruiser)": ItemData(318 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 18, classification=ItemClassification.filler), - "Defensive Matrix (Battlecruiser)": ItemData(319 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 19, classification=ItemClassification.filler), - "Ocular Implants (Ghost)": ItemData(320 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 20), - "Crius Suit (Ghost)": ItemData(321 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 21), - "Psionic Lash (Spectre)": ItemData(322 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 22, classification=ItemClassification.progression), - "Nyx-Class Cloaking Module (Spectre)": ItemData(323 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 23), - "330mm Barrage Cannon (Thor)": ItemData(324 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 24, classification=ItemClassification.filler), - "Immortality Protocol (Thor)": ItemData(325 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 25, classification=ItemClassification.filler), + "Twin-Linked Flamethrower (Hellion)": ItemData(300 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 0, classification=ItemClassification.filler, parent_item="Hellion"), + "Thermite Filaments (Hellion)": ItemData(301 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 1, parent_item="Hellion"), + "Cerberus Mine (Vulture)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler, parent_item="Vulture"), + "Replenishable Magazine (Vulture)": ItemData(303 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 3, classification=ItemClassification.filler, parent_item="Vulture"), + "Multi-Lock Weapons System (Goliath)": ItemData(304 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 4, parent_item="Goliath"), + "Ares-Class Targeting System (Goliath)": ItemData(305 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 5, parent_item="Goliath"), + "Tri-Lithium Power Cell (Diamondback)": ItemData(306 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 6, classification=ItemClassification.filler, parent_item="Diamondback"), + "Shaped Hull (Diamondback)": ItemData(307 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 7, classification=ItemClassification.filler, parent_item="Diamondback"), + "Maelstrom Rounds (Siege Tank)": ItemData(308 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 8, classification=ItemClassification.progression, parent_item="Siege Tank"), + "Shaped Blast (Siege Tank)": ItemData(309 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 9, parent_item="Siege Tank"), + "Rapid Deployment Tube (Medivac)": ItemData(310 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 10, classification=ItemClassification.filler, parent_item="Medivac"), + "Advanced Healing AI (Medivac)": ItemData(311 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 11, classification=ItemClassification.filler, parent_item="Medivac"), + "Tomahawk Power Cells (Wraith)": ItemData(312 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 12, classification=ItemClassification.filler, parent_item="Wraith"), + "Displacement Field (Wraith)": ItemData(313 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 13, classification=ItemClassification.filler, parent_item="Wraith"), + "Ripwave Missiles (Viking)": ItemData(314 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 14, parent_item="Viking"), + "Phobos-Class Weapons System (Viking)": ItemData(315 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 15, parent_item="Viking"), + "Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 16, classification=ItemClassification.filler, parent_item="Banshee"), + "Shockwave Missile Battery (Banshee)": ItemData(317 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 17, parent_item="Banshee"), + "Missile Pods (Battlecruiser)": ItemData(318 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 18, classification=ItemClassification.filler, parent_item="Battlecruiser"), + "Defensive Matrix (Battlecruiser)": ItemData(319 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 19, classification=ItemClassification.filler, parent_item="Battlecruiser"), + "Ocular Implants (Ghost)": ItemData(320 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 20, parent_item="Ghost"), + "Crius Suit (Ghost)": ItemData(321 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 21, parent_item="Ghost"), + "Psionic Lash (Spectre)": ItemData(322 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 22, classification=ItemClassification.progression, parent_item="Spectre"), + "Nyx-Class Cloaking Module (Spectre)": ItemData(323 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 23, parent_item="Spectre"), + "330mm Barrage Cannon (Thor)": ItemData(324 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 24, classification=ItemClassification.filler, parent_item="Thor"), + "Immortality Protocol (Thor)": ItemData(325 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 25, classification=ItemClassification.filler, parent_item="Thor"), "Bunker": ItemData(400 + SC2WOL_ITEM_ID_OFFSET, "Building", 0, classification=ItemClassification.progression), "Missile Turret": ItemData(401 + SC2WOL_ITEM_ID_OFFSET, "Building", 1, classification=ItemClassification.progression), @@ -117,9 +120,9 @@ item_table = { "Science Vessel": ItemData(607 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 7, classification=ItemClassification.progression), "Tech Reactor": ItemData(608 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 8), "Orbital Strike": ItemData(609 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 9), - "Shrike Turret": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10), - "Fortified Bunker": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11), - "Planetary Fortress": ItemData(612 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12), + "Shrike Turret": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10, parent_item="Bunker"), + "Fortified Bunker": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11, parent_item="Bunker"), + "Planetary Fortress": ItemData(612 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12, classification=ItemClassification.progression), "Perdition Turret": ItemData(613 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 13), "Predator": ItemData(614 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 14, classification=ItemClassification.filler), "Hercules": ItemData(615 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 15, classification=ItemClassification.progression), @@ -141,15 +144,33 @@ item_table = { "+15 Starting Minerals": ItemData(800 + SC2WOL_ITEM_ID_OFFSET, "Minerals", 15, quantity=0, classification=ItemClassification.filler), "+15 Starting Vespene": ItemData(801 + SC2WOL_ITEM_ID_OFFSET, "Vespene", 15, quantity=0, classification=ItemClassification.filler), "+2 Starting Supply": ItemData(802 + SC2WOL_ITEM_ID_OFFSET, "Supply", 2, quantity=0, classification=ItemClassification.filler), + + # "Keystone Piece": ItemData(850 + SC2WOL_ITEM_ID_OFFSET, "Goal", 0, quantity=0, classification=ItemClassification.progression_skip_balancing) } -basic_unit: typing.Tuple[str, ...] = ( + +basic_units = { 'Marine', 'Marauder', 'Firebat', 'Hellion', 'Vulture' -) +} + +advanced_basic_units = { + 'Reaper', + 'Goliath', + 'Diamondback', + 'Viking' +} + + +def get_basic_units(world: MultiWorld, player: int) -> set[str]: + if get_option_value(world, player, 'required_tactics') > 0: + return basic_units.union(advanced_basic_units) + else: + return basic_units + item_name_groups = {} for item, data in item_table.items(): @@ -176,4 +197,5 @@ type_flaggroups: typing.Dict[str, int] = { "Minerals": 8, "Vespene": 9, "Supply": 10, + "Goal": 11 } diff --git a/worlds/sc2wol/Locations.py b/worlds/sc2wol/Locations.py index 2dcd1017a9..358d0d227c 100644 --- a/worlds/sc2wol/Locations.py +++ b/worlds/sc2wol/Locations.py @@ -1,5 +1,6 @@ from typing import List, Tuple, Optional, Callable, NamedTuple from BaseClasses import MultiWorld +from .Options import get_option_value from BaseClasses import Location @@ -19,6 +20,7 @@ class LocationData(NamedTuple): def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[LocationData, ...]: # Note: rules which are ended with or True are rules identified as needed later when restricted units is an option + logic_level = get_option_value(world, player, 'required_tactics') location_table: List[LocationData] = [ LocationData("Liberation Day", "Liberation Day: Victory", SC2WOL_LOC_ID_OFFSET + 100), LocationData("Liberation Day", "Liberation Day: First Statue", SC2WOL_LOC_ID_OFFSET + 101), @@ -119,10 +121,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("Supernova", "Supernova: East Relic", SC2WOL_LOC_ID_OFFSET + 1104, lambda state: state._sc2wol_beats_protoss_deathball(world, player)), LocationData("Maw of the Void", "Maw of the Void: Victory", SC2WOL_LOC_ID_OFFSET + 1200, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: Landing Zone Cleared", SC2WOL_LOC_ID_OFFSET + 1201), LocationData("Maw of the Void", "Maw of the Void: Expansion Prisoners", SC2WOL_LOC_ID_OFFSET + 1202, lambda state: state._sc2wol_survives_rip_field(world, player)), @@ -252,9 +251,13 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L beat_events = [] - for location_data in location_table: - if location_data.name.endswith((": Victory", ": Defeat")): + for i in range(len(location_table)): + # Removing all item-based logic on No Logic + if logic_level == 2: + location_table[i] = location_table[i]._replace(rule=lambda state: True) + # Generating Beat event locations + if location_table[i].name.endswith((": Victory", ": Defeat")): beat_events.append( - location_data._replace(name="Beat " + location_data.name.rsplit(": ", 1)[0], code=None) + location_table[i]._replace(name="Beat " + location_table[i].name.rsplit(": ", 1)[0], code=None) ) return tuple(location_table + beat_events) diff --git a/worlds/sc2wol/LogicMixin.py b/worlds/sc2wol/LogicMixin.py index 8a0a718dcb..45b16dddc6 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -1,11 +1,12 @@ from BaseClasses import MultiWorld from worlds.AutoWorld import LogicMixin from .Options import get_option_value +from .Items import get_basic_units class SC2WoLLogic(LogicMixin): def _sc2wol_has_common_unit(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Marauder', 'Firebat', 'Hellion', 'Vulture'}, player) + return self.has_any(get_basic_units(world, player), player) def _sc2wol_has_manned_bunkers(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Marine', 'Marauder'}, player) and self.has('Bunker', player) @@ -21,10 +22,12 @@ class SC2WoLLogic(LogicMixin): return self.has_any({'Marine', 'Goliath'}, player) or self._sc2wol_has_air_anti_air(world, player) def _sc2wol_has_anti_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser'}, player) or self._sc2wol_has_competent_anti_air(world, player) + return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser'}, player) \ + or self._sc2wol_has_competent_anti_air(world, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any('Ghost', 'Spectre') def _sc2wol_has_heavy_defense(self, world: MultiWorld, player: int) -> bool: - return (self.has_any({'Siege Tank', 'Vulture'}, player) or + return (self.has_any({'Siege Tank', 'Vulture', 'Planetary Fortress'}, player) or self._sc2wol_has_manned_bunkers(world, player)) and self._sc2wol_has_anti_air(world, player) def _sc2wol_has_competent_comp(self, world: MultiWorld, player: int) -> bool: @@ -37,17 +40,19 @@ class SC2WoLLogic(LogicMixin): def _sc2wol_has_train_killers(self, world: MultiWorld, player: int) -> bool: return (self.has_any({'Siege Tank', 'Diamondback'}, player) or self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player) - or self.has('Marauders', player)) + or self.has('Marauder', player)) def _sc2wol_able_to_rescue(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) def _sc2wol_has_protoss_common_units(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) + return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) def _sc2wol_has_protoss_medium_units(self, world: MultiWorld, player: int) -> bool: return self._sc2wol_has_protoss_common_units(world, player) and \ - self.has_any({'Stalker', 'Void Ray', 'Phoenix', 'Carrier'}, player) + self.has_any({'Stalker', 'Void Ray', 'Phoenix', 'Carrier'}, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) def _sc2wol_beats_protoss_deathball(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Banshee', 'Battlecruiser'}, player) and self._sc2wol_has_competent_anti_air or \ @@ -62,12 +67,15 @@ class SC2WoLLogic(LogicMixin): self._sc2wol_has_competent_anti_air(world, player) and \ self.has("Science Vessel", player) + def _sc2wol_has_nukes(self, world: MultiWorld, player: int) -> bool: + return get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) + def _sc2wol_final_mission_requirements(self, world: MultiWorld, player: int): if get_option_value(world, player, 'all_in_map') == 0: # Ground version_logic = sum( - self.has(item, player) for item in ['Planetary Fortress', 'Siege Tank', 'Psi Disruptor', 'Banshee', 'Battlecruiser'] - ) + self._sc2wol_has_manned_bunkers(world, player) >= 3 + self.has(item, player) for item in ['Planetary Fortress', 'Siege Tank', 'Psi Disruptor', 'Banshee', 'Battlecruiser', 'Maelstrom Rounds'] + ) + self._sc2wol_has_manned_bunkers(world, player) >= 4 else: # Air version_logic = self.has_any({'Viking', 'Battlecruiser'}, player) \ diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index 7cb4520d45..56e6e0d991 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -1,5 +1,8 @@ from typing import NamedTuple, Dict, List +from BaseClasses import MultiWorld +from .Options import get_option_value + no_build_regions_list = ["Liberation Day", "Breakout", "Ghost of a Chance", "Piercing the Shroud", "Whispers of Doom", "Belly of the Beast"] easy_regions_list = ["The Outlaws", "Zero Hour", "Evacuation", "Outbreak", "Smash and Grab", "Devil's Playground"] @@ -115,9 +118,9 @@ mini_grid_order = [ FillMission("medium", [1, 5], "Colonist", or_requirements=True), FillMission("easy", [0], "Artifact"), FillMission("medium", [1, 3], "Artifact", or_requirements=True), - FillMission("hard", [2, 4, 8], "Artifact", or_requirements=True), + FillMission("hard", [2, 4], "Artifact", or_requirements=True), FillMission("medium", [3, 7], "Covert", or_requirements=True), - FillMission("hard", [4, 6, 8], "Covert", or_requirements=True), + FillMission("hard", [4, 6], "Covert", or_requirements=True), FillMission("all_in", [5, 7], "Covert", or_requirements=True) ] @@ -174,7 +177,6 @@ vanilla_mission_req_table = { lookup_id_to_mission: Dict[int, str] = { data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} - starting_mission_locations = { "Liberation Day": "Liberation Day: Victory", "Breakout": "Breakout: Victory", @@ -186,3 +188,15 @@ starting_mission_locations = { "Evacuation": "Evacuation: First Chysalis", "Devil's Playground": "Devil's Playground: Tosh's Miners" } + +advanced_starting_mission_locations = { + "Smash and Grab": "Smash and Grab: First Relic", + "The Great Train Robbery": "The Great Train Robbery: North Defiler" +} + + +def get_starting_mission_locations(world: MultiWorld, player: int) -> set[str]: + if get_option_value(world, player, 'required_tactics') > 0: + return {**starting_mission_locations, **advanced_starting_mission_locations} + else: + return starting_mission_locations diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index 5b76932a59..afbb30a022 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -1,6 +1,6 @@ from typing import Dict from BaseClasses import MultiWorld -from Options import Choice, Option, DefaultOnToggle, ItemSet +from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range class GameDifficulty(Choice): @@ -43,8 +43,7 @@ class MissionOrder(Choice): Grid: A 4x4 grid of random missions. Start at the top left and forge a path towards All-In. Mini Grid: A 3x3 version of Grid. Blitz: 10 random missions that open up very quickly. - Gauntlet: Linear series of 7 random missions to complete the campaign. - """ + Gauntlet: Linear series of 7 random missions to complete the campaign.""" display_name = "Mission Order" option_vanilla = 0 option_vanilla_shuffled = 1 @@ -57,16 +56,15 @@ class MissionOrder(Choice): class ShuffleProtoss(DefaultOnToggle): """Determines if the 3 protoss missions are included in the shuffle if Vanilla mission order is not enabled. - On Vanilla Shuffled, the 3 protoss missions will be in their normal position on the Prophecy chain if not shuffled. - On reduced mission settings, the 3 protoss missions will not appear and Protoss units are removed from the pool if not shuffled. - """ + If turned off with Vanilla Shuffled, the 3 protoss missions will be in their normal position on the Prophecy chain if not shuffled. + If turned off with reduced mission settings, the 3 protoss missions will not appear and Protoss units are removed from the pool.""" display_name = "Shuffle Protoss Missions" class RelegateNoBuildMissions(DefaultOnToggle): """Determines if the 5 no-build missions are included in the shuffle if Vanilla mission order is not enabled. - On Vanilla Shuffled, one no-build mission will be placed as the first mission and the rest will be placed at the end of optional routes. - On reduced mission settings, the 5 no-build missions will not appear.""" + If turned on with Vanilla Shuffled, one no-build mission will be placed as the first mission and the rest will be placed at the end of optional routes. + If turned on with reduced mission settings, the 5 no-build missions will not appear.""" display_name = "Relegate No-Build Missions" @@ -75,16 +73,40 @@ class EarlyUnit(DefaultOnToggle): display_name = "Early Unit" +class RequiredTactics(Choice): + """Determines the maximum tactical difficulty of the seed (separate from mission difficulty). Higher settings increase randomness. + Standard: All missions can be completed with good micro and macro. + Advanced: Completing missions may require relying on starting units and difficult-to-use units. + No Logic: Units and upgrades may be placed anywhere. LIKELY TO RENDER THE RUN IMPOSSIBLE ON HARDER DIFFICULTIES!""" + display_name = "Required Tactics" + option_standard = 0 + option_advanced = 1 + option_no_logic = 2 + + +class UnitsAlwaysHaveUpgrades(Toggle): + """If turned on, both upgrades will be present for each unit and structure in the seed. + This usually results in fewer units.""" + display_name = "Units Always Have Upgrades" + + class LockedItems(ItemSet): - """Guarantees that these items will appear in your world""" + """Guarantees that these items will be unlockable""" display_name = "Locked Items" class ExcludedItems(ItemSet): - """Guarantees that these items will not appear in your world""" + """Guarantees that these items will not be unlockable""" display_name = "Excluded Items" +class ExcludedMissions(OptionSet): + """Guarantees that these missions will not appear in the campaign + Only applies on shortened mission orders. + It may be impossible to build a valid campaign if too many missions are excluded.""" + display_name = "Excluded Missions" + + # noinspection PyTypeChecker sc2wol_options: Dict[str, Option] = { "game_difficulty": GameDifficulty, @@ -95,15 +117,27 @@ sc2wol_options: Dict[str, Option] = { "shuffle_protoss": ShuffleProtoss, "relegate_no_build": RelegateNoBuildMissions, "early_unit": EarlyUnit, + "required_tactics": RequiredTactics, + "units_always_have_upgrades": UnitsAlwaysHaveUpgrades, "locked_items": LockedItems, - "excluded_items": ExcludedItems + "excluded_items": ExcludedItems, + "excluded_missions": ExcludedMissions } def get_option_value(world: MultiWorld, player: int, name: str) -> int: option = getattr(world, name, None) - if option == None: + if option is None: return 0 return int(option[player].value) + + +def get_option_set_value(world: MultiWorld, player: int, name: str) -> int: + option = getattr(world, name, None) + + if option is None: + return set() + + return option[player].value diff --git a/worlds/sc2wol/PoolFilter.py b/worlds/sc2wol/PoolFilter.py index 21619c7e73..f6533959cf 100644 --- a/worlds/sc2wol/PoolFilter.py +++ b/worlds/sc2wol/PoolFilter.py @@ -2,8 +2,8 @@ from typing import Callable from BaseClasses import MultiWorld, ItemClassification, Item, Location from .Items import item_table from .MissionTables import no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list,\ - mission_orders, starting_mission_locations, MissionInfo -from .Options import get_option_value + mission_orders, get_starting_mission_locations, MissionInfo, vanilla_mission_req_table +from .Options import get_option_value, get_option_set_value from .LogicMixin import SC2WoLLogic # Items with associated upgrades @@ -17,19 +17,9 @@ UPGRADABLE_ITEMS = [ BARRACKS_UNITS = {"Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre"} FACTORY_UNITS = {"Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", "Predator"} STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "Hercules", "Science Vessel", "Raven"} -MIN_UNITS_PER_STRUCTURE = [ - 3, # Vanilla - 3, # Vanilla Shuffled - 2, # Mini Shuffle - 0 # Gauntlet -] PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} -ALWAYS_USEFUL_ARMORY = [ - "Combat Shield (Marine)", "Stabilizer Medpacks (Medic)" # Needed for no-build logic -] - def filter_missions(world: MultiWorld, player: int) -> dict[str, list[str]]: """ @@ -39,20 +29,19 @@ def filter_missions(world: MultiWorld, player: int) -> dict[str, list[str]]: mission_order_type = get_option_value(world, player, "mission_order") shuffle_protoss = get_option_value(world, player, "shuffle_protoss") relegate_no_build = get_option_value(world, player, "relegate_no_build") - - mission_count = 0 - for mission in mission_orders[mission_order_type]: - if mission.type == 'all_in': # All-In is placed separately - continue - mission_count += 1 - + excluded_missions: set[str] = get_option_set_value(world, player, "excluded_missions") + invalid_mission_names = excluded_missions.difference(vanilla_mission_req_table.keys()) + if invalid_mission_names: + raise Exception("Error in locked_missions - the following are not valid mission names: " + ", ".join(invalid_mission_names)) + mission_count = len(mission_orders[mission_order_type]) - 1 # Vanilla and Vanilla Shuffled use the entire mission pool if mission_count == 28: return { - 'no_build': no_build_regions_list[:], - 'easy': easy_regions_list[:], - 'medium': medium_regions_list[:], - 'hard': hard_regions_list[:] + "no_build": no_build_regions_list[:], + "easy": easy_regions_list[:], + "medium": medium_regions_list[:], + "hard": hard_regions_list[:], + "all_in": "all_in" } mission_sets = [ @@ -61,24 +50,25 @@ def filter_missions(world: MultiWorld, player: int) -> dict[str, list[str]]: set(medium_regions_list), set(hard_regions_list) ] - # Omitting Protoss missions if not shuffling protoss - if not shuffle_protoss: - for mission_set in mission_sets: - mission_set.difference_update(PROTOSS_REGIONS) # Omitting No Build missions if relegating no-build if relegate_no_build: # The build missions in starting_mission_locations become the new "no build missions" - mission_sets[0] = set(starting_mission_locations.keys()) + mission_sets[0] = set(get_starting_mission_locations(world, player).keys()) mission_sets[0].difference_update(no_build_regions_list) - # Future-proofing in case a non-Easy mission is placed in starting_mission_locations + # Removing the new no-build missions from their original sets for mission_set in mission_sets[1:]: mission_set.difference_update(mission_sets[0]) + # Omitting Protoss missions if not shuffling protoss + if not shuffle_protoss: + excluded_missions = excluded_missions.union(PROTOSS_REGIONS) + for mission_set in mission_sets: + mission_set.difference_update(excluded_missions) # Removing random missions from each difficulty set in a cycle set_cycle = 0 mission_pools = [list(mission_set) for mission_set in mission_sets] current_count = sum(len(mission_pool) for mission_pool in mission_pools) if current_count < mission_count: - raise Exception('Not enough missions available to fill the campaign on current settings.') + raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") while current_count > mission_count: if set_cycle == 4: set_cycle = 0 @@ -86,22 +76,25 @@ def filter_missions(world: MultiWorld, player: int) -> dict[str, list[str]]: mission_pool = mission_pools[set_cycle] set_cycle += 1 if len(mission_pool) == 1: + if all(len(other_pool) == 1 for other_pool in mission_pools): + raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") continue mission_pool.remove(world.random.choice(mission_pool)) current_count -= 1 + return { - 'no_build': mission_pools[0], - 'easy': mission_pools[1], - 'medium': mission_pools[2], - 'hard': mission_pools[3] + "no_build": mission_pools[0], + "easy": mission_pools[1], + "medium": mission_pools[2], + "hard": mission_pools[3] } -def filter_upgrades(inventory: list[Item], parent_item: Item or str): +def get_item_upgrades(inventory: list[Item], parent_item: Item or str): item_name = parent_item.name if isinstance(parent_item, Item) else parent_item return [ inv_item for inv_item in inventory - if inv_item.name in ALWAYS_USEFUL_ARMORY or not inv_item.name.endswith('(' + item_name + ')') + if item_table[inv_item.name].parent_item == item_name ] @@ -122,34 +115,64 @@ class ValidInventory: len(STARPORT_UNITS.intersection(self.logical_inventory)) > min_units_per_structure def generate_reduced_inventory(self, inventory_size: int, mission_requirements: list[Callable]) -> list[Item]: + """Attempts to generate a reduced inventory that can fulfill the mission requirements.""" inventory = list(self.item_pool) locked_items = list(self.locked_items) - self.logical_inventory = {item.name for item in self.progression_items.union(self.locked_items)} + self.logical_inventory = { + item.name for item in inventory + locked_items + if item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing) + } requirements = mission_requirements - mission_order_type = get_option_value(self.world, self.player, "mission_order") + cascade_keys = self.cascade_removal_map.keys() + units_always_have_upgrades = get_option_value(self.world, self.player, "units_always_have_upgrades") # Inventory restrictiveness based on number of missions with checks + mission_order_type = get_option_value(self.world, self.player, "mission_order") mission_count = len(mission_orders[mission_order_type]) - 1 min_units_per_structure = int(mission_count / 7) if min_units_per_structure > 0: requirements.append(lambda state: state.has_units_per_structure(min_units_per_structure)) - while len(inventory) + len(locked_items) > inventory_size: - if len(inventory) == 0: - raise Exception('Reduced item pool generation failed - not enough locations available to place items.') - # Select random item from removable items - item = self.world.random.choice(inventory) + + def attempt_removal(item: Item) -> bool: + # If item can be removed and has associated items, remove them as well inventory.remove(item) # Only run logic checks when removing logic items if item.name in self.logical_inventory: self.logical_inventory.remove(item.name) - if all(requirement(self) for requirement in requirements): - # If item can be removed and is a unit, remove armory upgrades - # Some armory upgrades are kept regardless, as they remain logically relevant - if item.name in UPGRADABLE_ITEMS: - inventory = filter_upgrades(inventory, item) - else: - # If item cannot be removed, move it to locked items + if not all(requirement(self) for requirement in requirements): + # If item cannot be removed, lock or revert self.logical_inventory.add(item.name) locked_items.append(item) + return False + return True + + while len(inventory) + len(locked_items) > inventory_size: + if len(inventory) == 0: + raise Exception("Reduced item pool generation failed - not enough locations available to place items.") + # Select random item from removable items + item = self.world.random.choice(inventory) + # Cascade removals to associated items + if item in cascade_keys: + items_to_remove = self.cascade_removal_map[item] + transient_items = [] + while len(items_to_remove) > 0: + item_to_remove = items_to_remove.pop() + if item_to_remove not in inventory: + continue + success = attempt_removal(item_to_remove) + if success: + transient_items.append(item_to_remove) + elif units_always_have_upgrades: + # Lock all associated items if any of them cannot be removed + transient_items += items_to_remove + locked_items += transient_items + self.logical_inventory = self.logical_inventory.union({ + transient_item.name for transient_item in transient_items + if item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing) + }) + break + else: + attempt_removal(item) + return inventory + locked_items def _read_logic(self): @@ -176,7 +199,6 @@ class ValidInventory: self.world = world self.player = player self.logical_inventory = set() - self.progression_items = set() self.locked_items = locked_items[:] self.existing_items = existing_items self._read_logic() @@ -185,9 +207,7 @@ class ValidInventory: item_quantities: dict[str, int] = dict() for item in item_pool: item_info = item_table[item.name] - if item.classification == ItemClassification.progression: - self.progression_items.add(item) - if item_info.type == 'Upgrade': + if item_info.type == "Upgrade": # All Upgrades are locked except for the final tier if item.name not in item_quantities: item_quantities[item.name] = 0 @@ -196,10 +216,19 @@ class ValidInventory: self.locked_items.append(item) else: self.item_pool.append(item) - elif item_info.type == 'Goal': + elif item_info.type == "Goal": locked_items.append(item) - elif item_info.type != 'Protoss' or has_protoss: + elif item_info.type != "Protoss" or has_protoss: self.item_pool.append(item) + self.cascade_removal_map: dict[Item, list[Item]] = dict() + for item in self.item_pool + locked_items + existing_items: + if item.name in UPGRADABLE_ITEMS: + upgrades = get_item_upgrades(self.item_pool, item) + associated_items = [*upgrades, item] + self.cascade_removal_map[item] = associated_items + if get_option_value(world, player, "units_always_have_upgrades"): + for upgrade in upgrades: + self.cascade_removal_map[upgrade] = associated_items def filter_items(world: MultiWorld, player: int, mission_req_table: dict[str, MissionInfo], location_cache: list[Location], diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index a3477ab2fd..b10d5dfde1 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -4,13 +4,13 @@ from typing import List, Set, Tuple from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification from worlds.AutoWorld import WebWorld, World from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups, get_full_item_list, \ - basic_unit + get_basic_units from .Locations import get_locations from .Regions import create_regions -from .Options import sc2wol_options, get_option_value +from .Options import sc2wol_options, get_option_value, get_option_set_value from .LogicMixin import SC2WoLLogic -from .PoolFilter import filter_missions, filter_items, filter_upgrades -from .MissionTables import starting_mission_locations, MissionInfo +from .PoolFilter import filter_missions, filter_items, get_item_upgrades +from .MissionTables import get_starting_mission_locations, MissionInfo class Starcraft2WoLWebWorld(WebWorld): @@ -132,12 +132,13 @@ def get_excluded_items(self: SC2WoLWorld, world: MultiWorld, player: int) -> Set def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str]) -> List[Item]: non_local_items = world.non_local_items[player].value if get_option_value(world, player, "early_unit"): - local_basic_unit = tuple(item for item in basic_unit if item not in non_local_items) + local_basic_unit = tuple(item for item in get_basic_units(world, player) if item not in non_local_items) if not local_basic_unit: raise Exception("At least one basic unit must be local") # The first world should also be the starting world first_mission = list(world.worlds[player].mission_req_table)[0] + starting_mission_locations = get_starting_mission_locations(world, player) if first_mission in starting_mission_locations: first_location = starting_mission_locations[first_mission] elif first_mission == "In Utter Darkness": @@ -174,8 +175,7 @@ def get_item_pool(world: MultiWorld, player: int, mission_req_table: dict[str, M locked_items = [] # YAML items - locked_items_option = getattr(world, 'locked_items', []) - yaml_locked_items = locked_items_option[player].value + yaml_locked_items = get_option_set_value(world, player, 'locked_items') for name, data in item_table.items(): if name not in excluded_items: @@ -187,10 +187,14 @@ def get_item_pool(world: MultiWorld, player: int, mission_req_table: dict[str, M pool.append(item) existing_items = starter_items + [item.name for item in world.precollected_items[player]] - + existing_names = [item.name for item in existing_items] # Removing upgrades for excluded items for item_name in excluded_items: - pool = filter_upgrades(pool, item_name) + if item_name in existing_names: + continue + invalid_upgrades = get_item_upgrades(pool, item_name) + for invalid_upgrade in invalid_upgrades: + pool.remove(invalid_upgrade) filtered_pool = filter_items(world, player, mission_req_table, location_cache, pool, existing_items, locked_items) return filtered_pool