From 700fe8b75e10a2e19e0f9b9986a440c30fff0819 Mon Sep 17 00:00:00 2001 From: Magnemania <89949176+Magnemania@users.noreply.github.com> Date: Wed, 26 Oct 2022 06:24:54 -0400 Subject: [PATCH] SC2: New Settings, Logic improvements (#1110) * Switched mission item group to a list comprehension to fix missile shuffle errors * Logic for reducing mission and item counts * SC2: Piercing the Shroud/Maw of the Void requirements now DRY * SC2: Logic for All-In, may need further refinement * SC2: Additional mission orders and starting locations * SC2: New Mission Order options for shorter campaigns and smaller item pools * Using location table for hardcoded starter unit * SC2: Options to curate random item pool and control early unit placement * SC2: Proper All-In logic * SC2: Grid, Mini Grid and Blitz mission orders * SC2: Required Tactics and Unit Upgrade options, better connected item handling * SC2: Client compatibility with Grid settings * SC2: Mission rando now uses world random * SC2: Alternate final missions, new logic, fixes * SC2: Handling alternate final missions, identifying final mission on client * SC2: Minor changes to handle edge-case generation failures * SC2: Removed invalid type hints for Python 3.8 * Revert "SC2: Removed invalid type hints for Python 3.8" This reverts commit 7851b9f7a39396c8ee1d85d4e4e46e61e8dc80f6. * SC2: Removed invalid type hints for Python 3.8 * SC2: Removed invalid type hints for Python 3.8 * SC2: Removed invalid type hints for Python 3.8 * SC2: Removed invalid type hints for Python 3.8 * SC2: Changed location loop to enumerate * SC2: Passing category names through slot data * SC2: Cleaned up unnecessary _create_items method * SC2: Removed vestigial extra_locations field from MissionInfo * SC2: Client backwards compatibility * SC2: Fixed item generation issue where item is present in both locked and unlocked inventories * SC2: Removed Missile Turret from defense rating on maps without air * SC2: No logic locations point to same access rule Co-authored-by: michaelasantiago Co-authored-by: Fabian Dill --- Starcraft2Client.py | 51 ++++++- worlds/sc2wol/Items.py | 140 +++++++++++------- worlds/sc2wol/Locations.py | 135 +++++++++-------- worlds/sc2wol/LogicMixin.py | 77 +++++++--- worlds/sc2wol/MissionTables.py | 182 +++++++++++++++++++---- worlds/sc2wol/Options.py | 91 ++++++++++-- worlds/sc2wol/PoolFilter.py | 257 +++++++++++++++++++++++++++++++++ worlds/sc2wol/Regions.py | 127 ++++++++-------- worlds/sc2wol/__init__.py | 91 ++++++++---- 9 files changed, 872 insertions(+), 279 deletions(-) create mode 100644 worlds/sc2wol/PoolFilter.py diff --git a/Starcraft2Client.py b/Starcraft2Client.py index de0a9041..7431b6ea 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -155,7 +155,9 @@ class SC2Context(CommonContext): items_handling = 0b111 difficulty = -1 all_in_choice = 0 + mission_order = 0 mission_req_table: typing.Dict[str, MissionInfo] = {} + final_mission: int = 29 announcements = queue.Queue() sc2_run_task: typing.Optional[asyncio.Task] = None missions_unlocked: bool = False # allow launching missions ignoring requirements @@ -180,9 +182,15 @@ class SC2Context(CommonContext): self.difficulty = args["slot_data"]["game_difficulty"] self.all_in_choice = args["slot_data"]["all_in_map"] slot_req_table = args["slot_data"]["mission_req"] + # Maintaining backwards compatibility with older slot data self.mission_req_table = { - mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table + mission: MissionInfo( + **{field: value for field, value in mission_info.items() if field in MissionInfo._fields} + ) + for mission, mission_info in slot_req_table.items() } + self.mission_order = args["slot_data"].get("mission_order", 0) + self.final_mission = args["slot_data"].get("final_mission", 29) self.build_location_to_mission_mapping() @@ -304,7 +312,6 @@ class SC2Context(CommonContext): self.refresh_from_launching = True self.mission_panel.clear_widgets() - if self.ctx.mission_req_table: self.last_checked_locations = self.ctx.checked_locations.copy() self.first_check = False @@ -322,17 +329,20 @@ class SC2Context(CommonContext): for category in categories: category_panel = MissionCategory() + if category.startswith('_'): + category_display_name = '' + else: + category_display_name = category category_panel.add_widget( - Label(text=category, size_hint_y=None, height=50, outline_width=1)) + Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) for mission in categories[category]: text: str = mission tooltip: str = "" - + mission_id: int = self.ctx.mission_req_table[mission].id # Map has uncollected locations if mission in unfinished_missions: text = f"[color=6495ED]{text}[/color]" - elif mission in available_missions: text = f"[color=FFFFFF]{text}[/color]" # Map requirements not met @@ -351,6 +361,16 @@ class SC2Context(CommonContext): remaining_location_names: typing.List[str] = [ self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) if loc in self.ctx.missing_locations] + + if mission_id == self.ctx.final_mission: + if mission in available_missions: + text = f"[color=FFBC95]{mission}[/color]" + else: + text = f"[color=D0C0BE]{mission}[/color]" + if tooltip: + tooltip += "\n" + tooltip += "Final Mission" + if remaining_location_names: if tooltip: tooltip += "\n" @@ -360,7 +380,7 @@ class SC2Context(CommonContext): mission_button = MissionButton(text=text, size_hint_y=None, height=50) mission_button.tooltip_text = tooltip mission_button.bind(on_press=self.mission_callback) - self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button + self.mission_id_to_button[mission_id] = mission_button category_panel.add_widget(mission_button) category_panel.add_widget(Label(text="")) @@ -469,6 +489,9 @@ wol_default_categories = [ "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", "Char", "Char", "Char", "Char" ] +wol_default_category_names = [ + "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char" +] def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]: @@ -586,7 +609,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): if self.can_read_game: if game_state & (1 << 1) and not self.mission_completed: - if self.mission_id != 29: + if self.mission_id != self.ctx.final_mission: print("Mission Completed") await self.ctx.send_msgs( [{"cmd": 'LocationChecks', @@ -742,13 +765,14 @@ def calc_available_missions(ctx: SC2Context, unlocks=None): return available_missions -def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete): +def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): """Returns a bool signifying if the mission has all requirements complete and can be done Arguments: ctx -- instance of SC2Context locations_to_check -- the mission string name to check missions_complete -- an int of how many missions have been completed + mission_path -- a list of missions that have already been checked """ if len(ctx.mission_req_table[mission_name].required_world) >= 1: # A check for when the requirements are being or'd @@ -766,7 +790,18 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete else: req_success = False + # Grid-specific logic (to avoid long path checks and infinite recursion) + if ctx.mission_order in (3, 4): + if req_success: + return True + else: + if req_mission is ctx.mission_req_table[mission_name].required_world[-1]: + return False + else: + continue + # Recursively check required mission to see if it's requirements are met, in case !collect has been done + # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): if not ctx.mission_req_table[mission_name].or_requirements: return False diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index 6bb74076..6cb768de 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), + "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), "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), + "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,16 +120,16 @@ 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), - "Perdition Turret": ItemData(613 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 13), + "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, classification=ItemClassification.progression), "Predator": ItemData(614 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 14, classification=ItemClassification.filler), "Hercules": ItemData(615 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 15, classification=ItemClassification.progression), "Cellular Reactor": ItemData(616 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 16, classification=ItemClassification.filler), "Regenerative Bio-Steel": ItemData(617 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 17, classification=ItemClassification.filler), - "Hive Mind Emulator": ItemData(618 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 18), - "Psi Disrupter": ItemData(619 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 19, classification=ItemClassification.filler), + "Hive Mind Emulator": ItemData(618 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 18, ItemClassification.progression), + "Psi Disrupter": ItemData(619 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 19, classification=ItemClassification.progression), "Zealot": ItemData(700 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 0, classification=ItemClassification.progression), "Stalker": ItemData(701 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 1, 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) -> typing.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(): @@ -161,6 +182,22 @@ filler_items: typing.Tuple[str, ...] = ( '+15 Starting Vespene' ) +defense_ratings = { + "Siege Tank": 5, + "Maelstrom Rounds": 2, + "Planetary Fortress": 3, + # Bunker w/ Marine/Marauder: 3, + "Perdition Turret": 2, + "Missile Turret": 2, + "Vulture": 2 +} +zerg_defense_ratings = { + "Perdition Turret": 2, + # Bunker w/ Firebat + "Hive Mind Emulator": 3, + "Psi Disruptor": 3 +} + lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in get_full_item_list().items() if data.code} # Map type to expected int @@ -176,4 +213,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 14dd25fd..f778c91b 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), @@ -32,26 +34,33 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Outlaws", "The Outlaws: Rebel Base", SC2WOL_LOC_ID_OFFSET + 201, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Zero Hour", "Zero Hour: Victory", SC2WOL_LOC_ID_OFFSET + 300, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 2 and + (logic_level > 0 or state._sc2wol_has_anti_air(world, player))), LocationData("Zero Hour", "Zero Hour: First Group Rescued", SC2WOL_LOC_ID_OFFSET + 301), LocationData("Zero Hour", "Zero Hour: Second Group Rescued", SC2WOL_LOC_ID_OFFSET + 302, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Zero Hour", "Zero Hour: Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 303, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 2), LocationData("Evacuation", "Evacuation: Victory", SC2WOL_LOC_ID_OFFSET + 400, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Evacuation", "Evacuation: First Chysalis", SC2WOL_LOC_ID_OFFSET + 401), LocationData("Evacuation", "Evacuation: Second Chysalis", SC2WOL_LOC_ID_OFFSET + 402, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Evacuation", "Evacuation: Third Chysalis", SC2WOL_LOC_ID_OFFSET + 403, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Outbreak", "Outbreak: Victory", SC2WOL_LOC_ID_OFFSET + 500, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 4 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Outbreak", "Outbreak: Left Infestor", SC2WOL_LOC_ID_OFFSET + 501, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Outbreak", "Outbreak: Right Infestor", SC2WOL_LOC_ID_OFFSET + 502, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Safe Haven", "Safe Haven: Victory", SC2WOL_LOC_ID_OFFSET + 600, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_competent_anti_air(world, player)), @@ -66,38 +75,48 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L state._sc2wol_has_competent_anti_air(world, player)), LocationData("Haven's Fall", "Haven's Fall: Victory", SC2WOL_LOC_ID_OFFSET + 700, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: North Hive", SC2WOL_LOC_ID_OFFSET + 701, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: East Hive", SC2WOL_LOC_ID_OFFSET + 702, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: South Hive", SC2WOL_LOC_ID_OFFSET + 703, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Smash and Grab", "Smash and Grab: Victory", SC2WOL_LOC_ID_OFFSET + 800, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Smash and Grab", "Smash and Grab: First Relic", SC2WOL_LOC_ID_OFFSET + 801), LocationData("Smash and Grab", "Smash and Grab: Second Relic", SC2WOL_LOC_ID_OFFSET + 802), LocationData("Smash and Grab", "Smash and Grab: Third Relic", SC2WOL_LOC_ID_OFFSET + 803, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Smash and Grab", "Smash and Grab: Fourth Relic", SC2WOL_LOC_ID_OFFSET + 804, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("The Dig", "The Dig: Victory", SC2WOL_LOC_ID_OFFSET + 900, - lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_anti_air(world, player) and - state._sc2wol_has_heavy_defense(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, False) >= 7), LocationData("The Dig", "The Dig: Left Relic", SC2WOL_LOC_ID_OFFSET + 901, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Dig", "The Dig: Right Ground Relic", SC2WOL_LOC_ID_OFFSET + 902, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Dig", "The Dig: Right Cliff Relic", SC2WOL_LOC_ID_OFFSET + 903, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Moebius Factor", "The Moebius Factor: Victory", SC2WOL_LOC_ID_OFFSET + 1000, - lambda state: state._sc2wol_has_air(world, player) and state._sc2wol_has_anti_air(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + (state._sc2wol_has_air(world, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(world, player))), LocationData("The Moebius Factor", "The Moebius Factor: South Rescue", SC2WOL_LOC_ID_OFFSET + 1003, lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Wall Rescue", SC2WOL_LOC_ID_OFFSET + 1004, @@ -109,7 +128,10 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Moebius Factor", "The Moebius Factor: Alive Inside Rescue", SC2WOL_LOC_ID_OFFSET + 1007, lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1008, - lambda state: state._sc2wol_has_air(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + (state._sc2wol_has_air(world, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(world, player))), LocationData("Supernova", "Supernova: Victory", SC2WOL_LOC_ID_OFFSET + 1100, lambda state: state._sc2wol_beats_protoss_deathball(world, player)), LocationData("Supernova", "Supernova: West Relic", SC2WOL_LOC_ID_OFFSET + 1101), @@ -119,37 +141,23 @@ 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.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: logic_level > 0 or state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: South Close Prisoners", SC2WOL_LOC_ID_OFFSET + 1203, - 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: logic_level > 0 or state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: South Far Prisoners", SC2WOL_LOC_ID_OFFSET + 1204, - 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: North Prisoners", SC2WOL_LOC_ID_OFFSET + 1205, - 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("Devil's Playground", "Devil's Playground: Victory", SC2WOL_LOC_ID_OFFSET + 1300, - lambda state: state._sc2wol_has_anti_air(world, player) and ( - state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), + lambda state: logic_level > 0 or + state._sc2wol_has_anti_air(world, player) and ( + state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Devil's Playground", "Devil's Playground: Tosh's Miners", SC2WOL_LOC_ID_OFFSET + 1301), LocationData("Devil's Playground", "Devil's Playground: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1302, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: logic_level > 0 or state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), LocationData("Welcome to the Jungle", "Welcome to the Jungle: Victory", SC2WOL_LOC_ID_OFFSET + 1400, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_competent_anti_air(world, player)), @@ -176,7 +184,8 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Great Train Robbery", "The Great Train Robbery: Mid Defiler", SC2WOL_LOC_ID_OFFSET + 1702), LocationData("The Great Train Robbery", "The Great Train Robbery: South Defiler", SC2WOL_LOC_ID_OFFSET + 1703), LocationData("Cutthroat", "Cutthroat: Victory", SC2WOL_LOC_ID_OFFSET + 1800, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + (logic_level > 0 or state._sc2wol_has_anti_air)), LocationData("Cutthroat", "Cutthroat: Mira Han", SC2WOL_LOC_ID_OFFSET + 1801, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Cutthroat", "Cutthroat: North Relic", SC2WOL_LOC_ID_OFFSET + 1802, @@ -208,40 +217,44 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Media Blitz", "Media Blitz: Science Facility", SC2WOL_LOC_ID_OFFSET + 2004), LocationData("Piercing the Shroud", "Piercing the Shroud: Victory", SC2WOL_LOC_ID_OFFSET + 2100, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Holding Cell Relic", SC2WOL_LOC_ID_OFFSET + 2101), LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk Relic", SC2WOL_LOC_ID_OFFSET + 2102, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: First Escape Relic", SC2WOL_LOC_ID_OFFSET + 2103, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Second Escape Relic", SC2WOL_LOC_ID_OFFSET + 2104, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk ", SC2WOL_LOC_ID_OFFSET + 2105, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Whispers of Doom", "Whispers of Doom: Victory", SC2WOL_LOC_ID_OFFSET + 2200), LocationData("Whispers of Doom", "Whispers of Doom: First Hatchery", SC2WOL_LOC_ID_OFFSET + 2201), LocationData("Whispers of Doom", "Whispers of Doom: Second Hatchery", SC2WOL_LOC_ID_OFFSET + 2202), LocationData("Whispers of Doom", "Whispers of Doom: Third Hatchery", SC2WOL_LOC_ID_OFFSET + 2203), LocationData("A Sinister Turn", "A Sinister Turn: Victory", SC2WOL_LOC_ID_OFFSET + 2300, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), - LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301), - LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302), + LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), + LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), LocationData("A Sinister Turn", "A Sinister Turn: Templar Archives", SC2WOL_LOC_ID_OFFSET + 2303, lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("Echoes of the Future", "Echoes of the Future: Victory", SC2WOL_LOC_ID_OFFSET + 2400, - lambda state: state._sc2wol_has_protoss_medium_units(world, player)), + lambda state: logic_level > 0 or state._sc2wol_has_protoss_medium_units(world, player)), LocationData("Echoes of the Future", "Echoes of the Future: Close Obelisk", SC2WOL_LOC_ID_OFFSET + 2401), LocationData("Echoes of the Future", "Echoes of the Future: West Obelisk", SC2WOL_LOC_ID_OFFSET + 2402, - lambda state: state._sc2wol_has_protoss_common_units(world, player)), + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), LocationData("In Utter Darkness", "In Utter Darkness: Defeat", SC2WOL_LOC_ID_OFFSET + 2500), LocationData("In Utter Darkness", "In Utter Darkness: Protoss Archive", SC2WOL_LOC_ID_OFFSET + 2501, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), LocationData("In Utter Darkness", "In Utter Darkness: Kills", SC2WOL_LOC_ID_OFFSET + 2502, lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("Gates of Hell", "Gates of Hell: Victory", SC2WOL_LOC_ID_OFFSET + 2600, - lambda state: state._sc2wol_has_competent_comp(world, player)), + lambda state: state._sc2wol_has_competent_comp(world, player) and + state._sc2wol_defense_rating(world, player, True) > 6), LocationData("Gates of Hell", "Gates of Hell: Large Army", SC2WOL_LOC_ID_OFFSET + 2601, - lambda state: state._sc2wol_has_competent_comp(world, player)), + lambda state: state._sc2wol_has_competent_comp(world, player) and + state._sc2wol_defense_rating(world, player, True) > 6), LocationData("Belly of the Beast", "Belly of the Beast: Victory", SC2WOL_LOC_ID_OFFSET + 2700), LocationData("Belly of the Beast", "Belly of the Beast: First Charge", SC2WOL_LOC_ID_OFFSET + 2701), LocationData("Belly of the Beast", "Belly of the Beast: Second Charge", SC2WOL_LOC_ID_OFFSET + 2702), @@ -258,15 +271,19 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Shatter the Sky", "Shatter the Sky: Leviathan", SC2WOL_LOC_ID_OFFSET + 2805, lambda state: state._sc2wol_has_competent_comp(world, player)), - LocationData("All-In", "All-In: Victory", None) + LocationData("All-In", "All-In: Victory", None, + lambda state: state._sc2wol_final_mission_requirements(world, player)) ] beat_events = [] - for location_data in location_table: + for i, location_data in enumerate(location_table): + # Removing all item-based logic on No Logic + if logic_level == 2: + location_table[i] = location_data._replace(rule=Location.access_rule) + # Generating Beat event locations if location_data.name.endswith((": Victory", ": Defeat")): beat_events.append( location_data._replace(name="Beat " + location_data.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 52bb6b09..1de82959 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -1,31 +1,43 @@ from BaseClasses import MultiWorld from worlds.AutoWorld import LogicMixin +from .Options import get_option_value +from .Items import get_basic_units, defense_ratings, zerg_defense_ratings class SC2WoLLogic(LogicMixin): def _sc2wol_has_common_unit(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Marauder', 'Firebat', 'Hellion', 'Vulture'}, player) - - def _sc2wol_has_bunker_unit(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Marauder'}, player) + return self.has_any(get_basic_units(world, player), player) def _sc2wol_has_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Viking', 'Wraith', 'Banshee'}, player) or \ - self.has_any({'Hercules', 'Medivac'}, player) and self._sc2wol_has_common_unit(world, player) + return self.has_any({'Viking', 'Wraith', 'Banshee'}, player) or get_option_value(world, player, 'required_tactics') > 0 \ + and self.has_any({'Hercules', 'Medivac'}, player) and self._sc2wol_has_common_unit(world, player) def _sc2wol_has_air_anti_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Viking', 'Wraith'}, player) + return self.has('Viking', player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has('Wraith', player) def _sc2wol_has_competent_anti_air(self, world: MultiWorld, player: int) -> bool: 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', 'Wraith'}, 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'}, player) - def _sc2wol_has_heavy_defense(self, world: MultiWorld, player: int) -> bool: - return (self.has_any({'Siege Tank', 'Vulture'}, player) or - self.has('Bunker', player) and self._sc2wol_has_bunker_unit(world, player)) and \ - self._sc2wol_has_anti_air(world, player) + def _sc2wol_defense_rating(self, world: MultiWorld, player: int, zerg_enemy: bool, air_enemy: bool = True) -> bool: + defense_score = sum((defense_ratings[item] for item in defense_ratings if self.has(item, player))) + if self.has_any({'Marine', 'Marauder'}, player) and self.has('Bunker', player): + defense_score += 3 + if zerg_enemy: + defense_score += sum((zerg_defense_ratings[item] for item in zerg_defense_ratings if self.has(item, player))) + if self.has('Firebat', player) and self.has('Bunker', player): + defense_score += 2 + if not air_enemy and self.has('Missile Turret', player): + defense_score -= defense_ratings['Missile Turret'] + # Advanced Tactics bumps defense rating requirements down by 2 + if get_option_value(world, player, 'required_tactics') > 0: + defense_score += 2 + return defense_score def _sc2wol_has_competent_comp(self, world: MultiWorld, player: int) -> bool: return (self.has('Marine', player) or self.has('Marauder', player) and @@ -35,25 +47,50 @@ class SC2WoLLogic(LogicMixin): self.has('Siege Tank', player) and self._sc2wol_has_competent_anti_air(world, player) 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)) + return (self.has_any({'Siege Tank', 'Diamondback', 'Marauder'}, player) or get_option_value(world, player, 'required_tactics') > 0 + and self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player)) def _sc2wol_able_to_rescue(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) + return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) or get_option_value(world, player, 'required_tactics') > 0 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 \ self._sc2wol_has_competent_comp(world, player) and self._sc2wol_has_air_anti_air(world, player) + def _sc2wol_has_mm_upgrade(self, world: MultiWorld, player: int) -> bool: + return self.has_any({"Combat Shield (Marine)", "Stabilizer Medpacks (Medic)"}, player) + + def _sc2wol_survives_rip_field(self, world: MultiWorld, player: int) -> bool: + return self.has("Battlecruiser", player) or \ + self._sc2wol_has_air(world, player) and \ + 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): + defense_rating = self._sc2wol_defense_rating(world, player, True) + beats_kerrigan = self.has_any({'Marine', 'Banshee', 'Ghost'}, player) or get_option_value(world, player, 'required_tactics') > 0 + if get_option_value(world, player, 'all_in_map') == 0: + # Ground + if self.has_any({'Battlecruiser', 'Banshee'}, player): + defense_rating += 3 + return defense_rating >= 12 and beats_kerrigan + else: + # Air + return defense_rating >= 8 and beats_kerrigan \ + and self.has_any({'Viking', 'Battlecruiser'}, player) \ + and self.has_any({'Hive Mind Emulator', 'Psi Disruptor', 'Missile Turret'}, player) + def _sc2wol_cleared_missions(self, world: MultiWorld, player: int, mission_count: int) -> bool: return self.has_group("Missions", player, mission_count) - - diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index 4f1b1157..8d069446 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -1,4 +1,7 @@ -from typing import NamedTuple, Dict, List +from typing import NamedTuple, Dict, List, Set + +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"] @@ -12,7 +15,6 @@ hard_regions_list = ["Maw of the Void", "Engine of Destruction", "In Utter Darkn class MissionInfo(NamedTuple): id: int - extra_locations: int required_world: List[int] category: str number: int = 0 # number of worlds need beaten @@ -62,38 +64,156 @@ vanilla_shuffle_order = [ FillMission("all_in", [26, 27], "Char", completion_critical=True, or_requirements=True) ] +mini_campaign_order = [ + FillMission("no_build", [-1], "Mar Sara", completion_critical=True), + FillMission("easy", [0], "Colonist"), + FillMission("medium", [1], "Colonist"), + FillMission("medium", [0], "Artifact", completion_critical=True), + FillMission("medium", [3], "Artifact", number=4, completion_critical=True), + FillMission("hard", [4], "Artifact", number=8, completion_critical=True), + FillMission("medium", [0], "Covert", number=2), + FillMission("hard", [6], "Covert"), + FillMission("medium", [0], "Rebellion", number=3), + FillMission("hard", [8], "Rebellion"), + FillMission("medium", [4], "Prophecy"), + FillMission("hard", [10], "Prophecy"), + FillMission("hard", [5], "Char", completion_critical=True), + FillMission("hard", [5], "Char", completion_critical=True), + FillMission("all_in", [12, 13], "Char", completion_critical=True, or_requirements=True) +] + +gauntlet_order = [ + FillMission("no_build", [-1], "I", completion_critical=True), + FillMission("easy", [0], "II", completion_critical=True), + FillMission("medium", [1], "III", completion_critical=True), + FillMission("medium", [2], "IV", completion_critical=True), + FillMission("hard", [3], "V", completion_critical=True), + FillMission("hard", [4], "VI", completion_critical=True), + FillMission("all_in", [5], "Final", completion_critical=True) +] + +grid_order = [ + FillMission("no_build", [-1], "_1"), + FillMission("medium", [0], "_1"), + FillMission("medium", [1, 6, 3], "_1", or_requirements=True), + FillMission("hard", [2, 7], "_1", or_requirements=True), + FillMission("easy", [0], "_2"), + FillMission("medium", [1, 4], "_2", or_requirements=True), + FillMission("hard", [2, 5, 10, 7], "_2", or_requirements=True), + FillMission("hard", [3, 6, 11], "_2", or_requirements=True), + FillMission("medium", [4, 9, 12], "_3", or_requirements=True), + FillMission("hard", [5, 8, 10, 13], "_3", or_requirements=True), + FillMission("hard", [6, 9, 11, 14], "_3", or_requirements=True), + FillMission("hard", [7, 10], "_3", or_requirements=True), + FillMission("hard", [8, 13], "_4", or_requirements=True), + FillMission("hard", [9, 12, 14], "_4", or_requirements=True), + FillMission("hard", [10, 13], "_4", or_requirements=True), + FillMission("all_in", [11, 14], "_4", or_requirements=True) +] + +mini_grid_order = [ + FillMission("no_build", [-1], "_1"), + FillMission("medium", [0], "_1"), + FillMission("medium", [1, 5], "_1", or_requirements=True), + FillMission("easy", [0], "_2"), + FillMission("medium", [1, 3], "_2", or_requirements=True), + FillMission("hard", [2, 4], "_2", or_requirements=True), + FillMission("medium", [3, 7], "_3", or_requirements=True), + FillMission("hard", [4, 6], "_3", or_requirements=True), + FillMission("all_in", [5, 7], "_3", or_requirements=True) +] + +blitz_order = [ + FillMission("no_build", [-1], "I"), + FillMission("easy", [-1], "I"), + FillMission("medium", [0, 1], "II", number=1, or_requirements=True), + FillMission("medium", [0, 1], "II", number=1, or_requirements=True), + FillMission("medium", [0, 1], "III", number=2, or_requirements=True), + FillMission("medium", [0, 1], "III", number=2, or_requirements=True), + FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), + FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), + FillMission("hard", [0, 1], "V", number=4, or_requirements=True), + FillMission("hard", [0, 1], "V", number=4, or_requirements=True), + FillMission("hard", [0, 1], "Final", number=5, or_requirements=True), + FillMission("all_in", [0, 1], "Final", number=5, or_requirements=True) +] + +mission_orders = [vanilla_shuffle_order, vanilla_shuffle_order, mini_campaign_order, grid_order, mini_grid_order, blitz_order, gauntlet_order] + vanilla_mission_req_table = { - "Liberation Day": MissionInfo(1, 7, [], "Mar Sara", completion_critical=True), - "The Outlaws": MissionInfo(2, 2, [1], "Mar Sara", completion_critical=True), - "Zero Hour": MissionInfo(3, 4, [2], "Mar Sara", completion_critical=True), - "Evacuation": MissionInfo(4, 4, [3], "Colonist"), - "Outbreak": MissionInfo(5, 3, [4], "Colonist"), - "Safe Haven": MissionInfo(6, 4, [5], "Colonist", number=7), - "Haven's Fall": MissionInfo(7, 4, [5], "Colonist", number=7), - "Smash and Grab": MissionInfo(8, 5, [3], "Artifact", completion_critical=True), - "The Dig": MissionInfo(9, 4, [8], "Artifact", number=8, completion_critical=True), - "The Moebius Factor": MissionInfo(10, 9, [9], "Artifact", number=11, completion_critical=True), - "Supernova": MissionInfo(11, 5, [10], "Artifact", number=14, completion_critical=True), - "Maw of the Void": MissionInfo(12, 6, [11], "Artifact", completion_critical=True), - "Devil's Playground": MissionInfo(13, 3, [3], "Covert", number=4), - "Welcome to the Jungle": MissionInfo(14, 4, [13], "Covert"), - "Breakout": MissionInfo(15, 3, [14], "Covert", number=8), - "Ghost of a Chance": MissionInfo(16, 6, [14], "Covert", number=8), - "The Great Train Robbery": MissionInfo(17, 4, [3], "Rebellion", number=6), - "Cutthroat": MissionInfo(18, 5, [17], "Rebellion"), - "Engine of Destruction": MissionInfo(19, 6, [18], "Rebellion"), - "Media Blitz": MissionInfo(20, 5, [19], "Rebellion"), - "Piercing the Shroud": MissionInfo(21, 6, [20], "Rebellion"), - "Whispers of Doom": MissionInfo(22, 4, [9], "Prophecy"), - "A Sinister Turn": MissionInfo(23, 4, [22], "Prophecy"), - "Echoes of the Future": MissionInfo(24, 3, [23], "Prophecy"), - "In Utter Darkness": MissionInfo(25, 3, [24], "Prophecy"), - "Gates of Hell": MissionInfo(26, 2, [12], "Char", completion_critical=True), - "Belly of the Beast": MissionInfo(27, 4, [26], "Char", completion_critical=True), - "Shatter the Sky": MissionInfo(28, 5, [26], "Char", completion_critical=True), - "All-In": MissionInfo(29, -1, [27, 28], "Char", completion_critical=True, or_requirements=True) + "Liberation Day": MissionInfo(1, [], "Mar Sara", completion_critical=True), + "The Outlaws": MissionInfo(2, [1], "Mar Sara", completion_critical=True), + "Zero Hour": MissionInfo(3, [2], "Mar Sara", completion_critical=True), + "Evacuation": MissionInfo(4, [3], "Colonist"), + "Outbreak": MissionInfo(5, [4], "Colonist"), + "Safe Haven": MissionInfo(6, [5], "Colonist", number=7), + "Haven's Fall": MissionInfo(7, [5], "Colonist", number=7), + "Smash and Grab": MissionInfo(8, [3], "Artifact", completion_critical=True), + "The Dig": MissionInfo(9, [8], "Artifact", number=8, completion_critical=True), + "The Moebius Factor": MissionInfo(10, [9], "Artifact", number=11, completion_critical=True), + "Supernova": MissionInfo(11, [10], "Artifact", number=14, completion_critical=True), + "Maw of the Void": MissionInfo(12, [11], "Artifact", completion_critical=True), + "Devil's Playground": MissionInfo(13, [3], "Covert", number=4), + "Welcome to the Jungle": MissionInfo(14, [13], "Covert"), + "Breakout": MissionInfo(15, [14], "Covert", number=8), + "Ghost of a Chance": MissionInfo(16, [14], "Covert", number=8), + "The Great Train Robbery": MissionInfo(17, [3], "Rebellion", number=6), + "Cutthroat": MissionInfo(18, [17], "Rebellion"), + "Engine of Destruction": MissionInfo(19, [18], "Rebellion"), + "Media Blitz": MissionInfo(20, [19], "Rebellion"), + "Piercing the Shroud": MissionInfo(21, [20], "Rebellion"), + "Whispers of Doom": MissionInfo(22, [9], "Prophecy"), + "A Sinister Turn": MissionInfo(23, [22], "Prophecy"), + "Echoes of the Future": MissionInfo(24, [23], "Prophecy"), + "In Utter Darkness": MissionInfo(25, [24], "Prophecy"), + "Gates of Hell": MissionInfo(26, [12], "Char", completion_critical=True), + "Belly of the Beast": MissionInfo(27, [26], "Char", completion_critical=True), + "Shatter the Sky": MissionInfo(28, [26], "Char", completion_critical=True), + "All-In": MissionInfo(29, [27, 28], "Char", completion_critical=True, or_requirements=True) } lookup_id_to_mission: Dict[int, str] = { data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} + +no_build_starting_mission_locations = { + "Liberation Day": "Liberation Day: Victory", + "Breakout": "Breakout: Victory", + "Ghost of a Chance": "Ghost of a Chance: Victory", + "Piercing the Shroud": "Piercing the Shroud: Victory", + "Whispers of Doom": "Whispers of Doom: Victory", + "Belly of the Beast": "Belly of the Beast: Victory", +} + +build_starting_mission_locations = { + "Zero Hour": "Zero Hour: First Group Rescued", + "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, 'shuffle_no_build') or get_option_value(world, player, 'mission_order') < 2: + # Always start with a no-build mission unless explicitly relegating them + # Vanilla and Vanilla Shuffled always start with a no-build even when relegated + return no_build_starting_mission_locations + elif get_option_value(world, player, 'required_tactics') > 0: + # Advanced Tactics/No Logic add more starting missions to the pool + return {**build_starting_mission_locations, **advanced_starting_mission_locations} + else: + # Standard starting missions when relegate is on + return build_starting_mission_locations + + +alt_final_mission_locations = { + "Maw of the Void": "Maw of the Void: Victory", + "Engine of Destruction": "Engine of Destruction: Victory", + "Supernova": "Supernova: Victory", + "Gates of Hell": "Gates of Hell: Victory", + "Shatter the Sky": "Shatter the Sky: Victory" +} \ No newline at end of file diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index efd08725..9cd86f2c 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 +from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range class GameDifficulty(Choice): @@ -36,25 +36,75 @@ class AllInMap(Choice): class MissionOrder(Choice): - """Determines the order the missions are played in. - Vanilla: Keeps the standard mission order and branching from the WoL Campaign. - Vanilla Shuffled: Keeps same branching paths from the WoL Campaign but randomizes the order of missions within.""" + """Determines the order the missions are played in. The last three mission orders end in a random mission. + Vanilla (29): Keeps the standard mission order and branching from the WoL Campaign. + Vanilla Shuffled (29): Keeps same branching paths from the WoL Campaign but randomizes the order of missions within. + Mini Campaign (15): Shorter version of the campaign with randomized missions and optional branches. + Grid (16): A 4x4 grid of random missions. Start at the top-left and forge a path towards All-In. + Mini Grid (9): A 3x3 version of Grid. Complete the bottom-right mission to win. + Blitz (12): 12 random missions that open up very quickly. Complete the bottom-right mission to win. + Gauntlet (7): Linear series of 7 random missions to complete the campaign.""" display_name = "Mission Order" option_vanilla = 0 option_vanilla_shuffled = 1 + option_mini_campaign = 2 + option_grid = 3 + option_mini_grid = 4 + option_blitz = 5 + option_gauntlet = 6 class ShuffleProtoss(DefaultOnToggle): - """Determines if the 3 protoss missions are included in the shuffle if Vanilla Shuffled is enabled. If this is - not the 3 protoss missions will stay in their vanilla order in the mission order making them optional to complete - the game.""" + """Determines if the 3 protoss missions are included in the shuffle if Vanilla mission order is not enabled. + 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): - """If enabled, all no build missions besides the needed first one will be placed at the end of optional routes so - that none of them become required to complete the game. Only takes effect if mission order is not set to vanilla.""" - display_name = "Relegate No-Build Missions" +class ShuffleNoBuild(DefaultOnToggle): + """Determines if the 5 no-build missions are included in the shuffle if Vanilla mission order is not enabled. + If turned off 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 off with reduced mission settings, the 5 no-build missions will not appear.""" + display_name = "Shuffle No-Build Missions" + + +class EarlyUnit(DefaultOnToggle): + """Guarantees that the first mission will contain a unit.""" + 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 micro-heavy 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(DefaultOnToggle): + """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 be unlockable""" + display_name = "Locked Items" + + +class ExcludedItems(ItemSet): + """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 @@ -65,14 +115,29 @@ sc2wol_options: Dict[str, Option] = { "all_in_map": AllInMap, "mission_order": MissionOrder, "shuffle_protoss": ShuffleProtoss, - "relegate_no_build": RelegateNoBuildMissions + "shuffle_no_build": ShuffleNoBuild, + "early_unit": EarlyUnit, + "required_tactics": RequiredTactics, + "units_always_have_upgrades": UnitsAlwaysHaveUpgrades, + "locked_items": LockedItems, + "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) -> set: + 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 new file mode 100644 index 00000000..5b6970e7 --- /dev/null +++ b/worlds/sc2wol/PoolFilter.py @@ -0,0 +1,257 @@ +from typing import Callable, Dict, List, Set +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, get_starting_mission_locations, MissionInfo, vanilla_mission_req_table, alt_final_mission_locations +from .Options import get_option_value, get_option_set_value +from .LogicMixin import SC2WoLLogic + +# Items with associated upgrades +UPGRADABLE_ITEMS = [ + "Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre", + "Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", + "Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", + "Bunker", "Missile Turret" +] + +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"} + +PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} + + +def filter_missions(world: MultiWorld, player: int) -> Dict[str, List[str]]: + """ + Returns a semi-randomly pruned tuple of no-build, easy, medium, and hard mission sets + """ + + mission_order_type = get_option_value(world, player, "mission_order") + shuffle_protoss = get_option_value(world, player, "shuffle_protoss") + excluded_missions = set(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[:], + "all_in": ["All-In"] + } + + mission_pools = [ + [], + easy_regions_list, + medium_regions_list, + hard_regions_list + ] + # Omitting Protoss missions if not shuffling protoss + if not shuffle_protoss: + excluded_missions = excluded_missions.union(PROTOSS_REGIONS) + # Replacing All-In on low mission counts + if mission_count < 14: + final_mission = world.random.choice([mission for mission in alt_final_mission_locations.keys() if mission not in excluded_missions]) + excluded_missions.add(final_mission) + else: + final_mission = 'All-In' + # Yaml settings determine which missions can be placed in the first slot + mission_pools[0] = [mission for mission in get_starting_mission_locations(world, player).keys() if mission not in excluded_missions] + # Removing the new no-build missions from their original sets + for i in range(1, len(mission_pools)): + mission_pools[i] = [mission for mission in mission_pools[i] if mission not in excluded_missions.union(mission_pools[0])] + # If the first mission is a build mission, there may not be enough locations to reach Outbreak as a second mission + if not get_option_value(world, player, 'shuffle_no_build'): + # Swapping Outbreak and The Great Train Robbery + if "Outbreak" in mission_pools[1]: + mission_pools[1].remove("Outbreak") + mission_pools[2].append("Outbreak") + if "The Great Train Robbery" in mission_pools[2]: + mission_pools[2].remove("The Great Train Robbery") + mission_pools[1].append("The Great Train Robbery") + # Removing random missions from each difficulty set in a cycle + set_cycle = 0 + 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. Please exclude fewer missions.") + while current_count > mission_count: + if set_cycle == 4: + set_cycle = 0 + # Must contain at least one mission per set + mission_pool = mission_pools[set_cycle] + if len(mission_pool) <= 1: + if all(len(mission_pool) <= 1 for mission_pool in mission_pools): + raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") + else: + mission_pool.remove(world.random.choice(mission_pool)) + current_count -= 1 + set_cycle += 1 + + return { + "no_build": mission_pools[0], + "easy": mission_pools[1], + "medium": mission_pools[2], + "hard": mission_pools[3], + "all_in": [final_mission] + } + + +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 item_table[inv_item.name].parent_item == item_name + ] + + +class ValidInventory: + + def has(self, item: str, player: int): + return item in self.logical_inventory + + def has_any(self, items: Set[str], player: int): + return any(item in self.logical_inventory for item in items) + + def has_all(self, items: Set[str], player: int): + return all(item in self.logical_inventory for item in items) + + def has_units_per_structure(self) -> bool: + return len(BARRACKS_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure and \ + len(FACTORY_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure and \ + len(STARPORT_UNITS.intersection(self.logical_inventory)) > self.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 inventory + locked_items + self.existing_items + if item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing) + } + requirements = mission_requirements + cascade_keys = self.cascade_removal_map.keys() + units_always_have_upgrades = get_option_value(self.world, self.player, "units_always_have_upgrades") + if self.min_units_per_structure > 0: + requirements.append(lambda state: state.has_units_per_structure()) + + 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 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 + for transient_item in transient_items: + if transient_item not in inventory and transient_item not in locked_items: + locked_items += transient_item + if transient_item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing): + self.logical_inventory.add(transient_item.name) + break + else: + attempt_removal(item) + + return inventory + locked_items + + def _read_logic(self): + self._sc2wol_has_common_unit = lambda world, player: SC2WoLLogic._sc2wol_has_common_unit(self, world, player) + self._sc2wol_has_air = lambda world, player: SC2WoLLogic._sc2wol_has_air(self, world, player) + self._sc2wol_has_air_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_air_anti_air(self, world, player) + self._sc2wol_has_competent_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_competent_anti_air(self, world, player) + self._sc2wol_has_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_anti_air(self, world, player) + self._sc2wol_defense_rating = lambda world, player, zerg_enemy, air_enemy=False: SC2WoLLogic._sc2wol_defense_rating(self, world, player, zerg_enemy, air_enemy) + self._sc2wol_has_competent_comp = lambda world, player: SC2WoLLogic._sc2wol_has_competent_comp(self, world, player) + self._sc2wol_has_train_killers = lambda world, player: SC2WoLLogic._sc2wol_has_train_killers(self, world, player) + self._sc2wol_able_to_rescue = lambda world, player: SC2WoLLogic._sc2wol_able_to_rescue(self, world, player) + self._sc2wol_beats_protoss_deathball = lambda world, player: SC2WoLLogic._sc2wol_beats_protoss_deathball(self, world, player) + self._sc2wol_survives_rip_field = lambda world, player: SC2WoLLogic._sc2wol_survives_rip_field(self, world, player) + self._sc2wol_has_protoss_common_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_common_units(self, world, player) + self._sc2wol_has_protoss_medium_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_medium_units(self, world, player) + self._sc2wol_has_mm_upgrade = lambda world, player: SC2WoLLogic._sc2wol_has_mm_upgrade(self, world, player) + self._sc2wol_final_mission_requirements = lambda world, player: SC2WoLLogic._sc2wol_final_mission_requirements(self, world, player) + + def __init__(self, world: MultiWorld, player: int, + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item], + has_protoss: bool): + self.world = world + self.player = player + self.logical_inventory = set() + self.locked_items = locked_items[:] + self.existing_items = existing_items + self._read_logic() + # Initial filter of item pool + self.item_pool = [] + item_quantities: dict[str, int] = dict() + # 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 + self.min_units_per_structure = int(mission_count / 7) + min_upgrades = 1 if mission_count < 10 else 2 + for item in item_pool: + item_info = item_table[item.name] + if item_info.type == "Upgrade": + # Locking upgrades based on mission duration + if item.name not in item_quantities: + item_quantities[item.name] = 0 + item_quantities[item.name] += 1 + if item_quantities[item.name] < min_upgrades: + self.locked_items.append(item) + else: + self.item_pool.append(item) + elif item_info.type == "Goal": + locked_items.append(item) + 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], + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item]) -> List[Item]: + """ + Returns a semi-randomly pruned set of items based on number of available locations. + The returned inventory must be capable of logically accessing every location in the world. + """ + open_locations = [location for location in location_cache if location.item is None] + inventory_size = len(open_locations) + has_protoss = bool(PROTOSS_REGIONS.intersection(mission_req_table.keys())) + mission_requirements = [location.access_rule for location in location_cache] + valid_inventory = ValidInventory(world, player, item_pool, existing_items, locked_items, has_protoss) + + valid_items = valid_inventory.generate_reduced_inventory(inventory_size, mission_requirements) + return valid_items diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index 8219a982..b0a3a51e 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -2,55 +2,47 @@ from typing import List, Set, Dict, Tuple, Optional, Callable from BaseClasses import MultiWorld, Region, Entrance, Location, RegionType from .Locations import LocationData from .Options import get_option_value -from .MissionTables import MissionInfo, vanilla_shuffle_order, vanilla_mission_req_table, \ - no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list +from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations +from .PoolFilter import filter_missions import random -def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location]): +def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location])\ + -> Tuple[Dict[str, MissionInfo], int, str]: locations_per_region = get_locations_per_region(locations) - regions = [ - create_region(world, player, locations_per_region, location_cache, "Menu"), - create_region(world, player, locations_per_region, location_cache, "Liberation Day"), - create_region(world, player, locations_per_region, location_cache, "The Outlaws"), - create_region(world, player, locations_per_region, location_cache, "Zero Hour"), - create_region(world, player, locations_per_region, location_cache, "Evacuation"), - create_region(world, player, locations_per_region, location_cache, "Outbreak"), - create_region(world, player, locations_per_region, location_cache, "Safe Haven"), - create_region(world, player, locations_per_region, location_cache, "Haven's Fall"), - create_region(world, player, locations_per_region, location_cache, "Smash and Grab"), - create_region(world, player, locations_per_region, location_cache, "The Dig"), - create_region(world, player, locations_per_region, location_cache, "The Moebius Factor"), - create_region(world, player, locations_per_region, location_cache, "Supernova"), - create_region(world, player, locations_per_region, location_cache, "Maw of the Void"), - create_region(world, player, locations_per_region, location_cache, "Devil's Playground"), - create_region(world, player, locations_per_region, location_cache, "Welcome to the Jungle"), - create_region(world, player, locations_per_region, location_cache, "Breakout"), - create_region(world, player, locations_per_region, location_cache, "Ghost of a Chance"), - create_region(world, player, locations_per_region, location_cache, "The Great Train Robbery"), - create_region(world, player, locations_per_region, location_cache, "Cutthroat"), - create_region(world, player, locations_per_region, location_cache, "Engine of Destruction"), - create_region(world, player, locations_per_region, location_cache, "Media Blitz"), - create_region(world, player, locations_per_region, location_cache, "Piercing the Shroud"), - create_region(world, player, locations_per_region, location_cache, "Whispers of Doom"), - create_region(world, player, locations_per_region, location_cache, "A Sinister Turn"), - create_region(world, player, locations_per_region, location_cache, "Echoes of the Future"), - create_region(world, player, locations_per_region, location_cache, "In Utter Darkness"), - create_region(world, player, locations_per_region, location_cache, "Gates of Hell"), - create_region(world, player, locations_per_region, location_cache, "Belly of the Beast"), - create_region(world, player, locations_per_region, location_cache, "Shatter the Sky"), - create_region(world, player, locations_per_region, location_cache, "All-In") - ] + mission_order_type = get_option_value(world, player, "mission_order") + mission_order = mission_orders[mission_order_type] + + mission_pools = filter_missions(world, player) + final_mission = mission_pools['all_in'][0] + + used_regions = [mission for mission_pool in mission_pools.values() for mission in mission_pool] + regions = [create_region(world, player, locations_per_region, location_cache, "Menu")] + for region_name in used_regions: + regions.append(create_region(world, player, locations_per_region, location_cache, region_name)) + # Changing the completion condition for alternate final missions into an event + if final_mission != 'All-In': + final_location = alt_final_mission_locations[final_mission] + # Final location should be near the end of the cache + for i in range(len(location_cache) - 1, -1, -1): + if location_cache[i].name == final_location: + location_cache[i].locked = True + location_cache[i].event = True + location_cache[i].address = None + break + else: + final_location = 'All-In: Victory' if __debug__: - throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) + if mission_order_type in (0, 1): + throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) world.regions += regions names: Dict[str, int] = {} - if get_option_value(world, player, "mission_order") == 0: + if mission_order_type == 0: connect(world, player, names, 'Menu', 'Liberation Day'), connect(world, player, names, 'Liberation Day', 'The Outlaws', lambda state: state.has("Beat Liberation Day", player)), @@ -119,32 +111,30 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData lambda state: state.has('Beat Gates of Hell', player) and ( state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player))) - return vanilla_mission_req_table + return vanilla_mission_req_table, 29, final_location - elif get_option_value(world, player, "mission_order") == 1: + else: missions = [] - no_build_pool = no_build_regions_list[:] - easy_pool = easy_regions_list[:] - medium_pool = medium_regions_list[:] - hard_pool = hard_regions_list[:] # Initial fill out of mission list and marking all-in mission - for mission in vanilla_shuffle_order: - if mission.type == "all_in": - missions.append("All-In") - elif get_option_value(world, player, "relegate_no_build") and mission.relegate: + for mission in mission_order: + if mission is None: + missions.append(None) + elif mission.type == "all_in": + missions.append(final_mission) + elif mission.relegate and not get_option_value(world, player, "shuffle_no_build"): missions.append("no_build") else: missions.append(mission.type) - # Place Protoss Missions if we are not using ShuffleProtoss - if get_option_value(world, player, "shuffle_protoss") == 0: + # Place Protoss Missions if we are not using ShuffleProtoss and are in Vanilla Shuffled + if get_option_value(world, player, "shuffle_protoss") == 0 and mission_order_type == 1: missions[22] = "A Sinister Turn" - medium_pool.remove("A Sinister Turn") + mission_pools['medium'].remove("A Sinister Turn") missions[23] = "Echoes of the Future" - medium_pool.remove("Echoes of the Future") + mission_pools['medium'].remove("Echoes of the Future") missions[24] = "In Utter Darkness" - hard_pool.remove("In Utter Darkness") + mission_pools['hard'].remove("In Utter Darkness") no_build_slots = [] easy_slots = [] @@ -153,6 +143,8 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData # Search through missions to find slots needed to fill for i in range(len(missions)): + if missions[i] is None: + continue if missions[i] == "no_build": no_build_slots.append(i) elif missions[i] == "easy": @@ -163,30 +155,30 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData hard_slots.append(i) # Add no_build missions to the pool and fill in no_build slots - missions_to_add = no_build_pool + missions_to_add = mission_pools['no_build'] for slot in no_build_slots: - filler = random.randint(0, len(missions_to_add)-1) + filler = world.random.randint(0, len(missions_to_add)-1) missions[slot] = missions_to_add.pop(filler) # Add easy missions into pool and fill in easy slots - missions_to_add = missions_to_add + easy_pool + missions_to_add = missions_to_add + mission_pools['easy'] for slot in easy_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add medium missions into pool and fill in medium slots - missions_to_add = missions_to_add + medium_pool + missions_to_add = missions_to_add + mission_pools['medium'] for slot in medium_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add hard missions into pool and fill in hard slots - missions_to_add = missions_to_add + hard_pool + missions_to_add = missions_to_add + mission_pools['hard'] for slot in hard_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) @@ -195,7 +187,7 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData mission_req_table = {} for i in range(len(missions)): connections = [] - for connection in vanilla_shuffle_order[i].connect_to: + for connection in mission_order[i].connect_to: if connection == -1: connect(world, player, names, "Menu", missions[i]) else: @@ -203,16 +195,17 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData (lambda name, missions_req: (lambda state: state.has(f"Beat {name}", player) and state._sc2wol_cleared_missions(world, player, missions_req))) - (missions[connection], vanilla_shuffle_order[i].number)) + (missions[connection], mission_order[i].number)) connections.append(connection + 1) mission_req_table.update({missions[i]: MissionInfo( - vanilla_mission_req_table[missions[i]].id, vanilla_mission_req_table[missions[i]].extra_locations, - connections, vanilla_shuffle_order[i].category, number=vanilla_shuffle_order[i].number, - completion_critical=vanilla_shuffle_order[i].completion_critical, - or_requirements=vanilla_shuffle_order[i].or_requirements)}) + vanilla_mission_req_table[missions[i]].id, connections, mission_order[i].category, + number=mission_order[i].number, + completion_critical=mission_order[i].completion_critical, + or_requirements=mission_order[i].or_requirements)}) - return mission_req_table + final_mission_id = vanilla_mission_req_table[final_mission].id + return mission_req_table, final_mission_id, final_mission + ': Victory' def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 6d056df8..70226e7a 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -1,14 +1,16 @@ import typing -from typing import List, Set, Tuple +from typing import List, Set, Tuple, Dict 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, get_item_upgrades +from .MissionTables import get_starting_mission_locations, MissionInfo class Starcraft2WoLWebWorld(WebWorld): @@ -42,6 +44,8 @@ class SC2WoLWorld(World): locked_locations: typing.List[str] location_cache: typing.List[Location] mission_req_table = {} + final_mission_id: int + victory_item: str required_client_version = 0, 3, 5 def __init__(self, world: MultiWorld, player: int): @@ -49,24 +53,21 @@ class SC2WoLWorld(World): self.location_cache = [] self.locked_locations = [] - def _create_items(self, name: str): - data = get_full_item_list()[name] - return [self.create_item(name) for _ in range(data.quantity)] - def create_item(self, name: str) -> Item: data = get_full_item_list()[name] return StarcraftWoLItem(name, data.classification, data.code, self.player) def create_regions(self): - self.mission_req_table = create_regions(self.world, self.player, get_locations(self.world, self.player), - self.location_cache) + self.mission_req_table, self.final_mission_id, self.victory_item = create_regions( + self.world, self.player, get_locations(self.world, self.player), self.location_cache + ) def generate_basic(self): excluded_items = get_excluded_items(self, self.world, self.player) - assign_starter_items(self.world, self.player, excluded_items, self.locked_locations) + starter_items = assign_starter_items(self.world, self.player, excluded_items, self.locked_locations) - pool = get_item_pool(self.world, self.player, excluded_items) + pool = get_item_pool(self.world, self.player, self.mission_req_table, starter_items, excluded_items, self.location_cache) fill_item_pool_with_dummy_items(self, self.world, self.player, self.locked_locations, self.location_cache, pool) @@ -74,8 +75,7 @@ class SC2WoLWorld(World): def set_rules(self): setup_events(self.world, self.player, self.locked_locations, self.location_cache) - - self.world.completion_condition[self.player] = lambda state: state.has('All-In: Victory', self.player) + self.world.completion_condition[self.player] = lambda state: state.has(self.victory_item, self.player) def get_filler_item_name(self) -> str: return self.world.random.choice(filler_items) @@ -91,6 +91,7 @@ class SC2WoLWorld(World): slot_req_table[mission] = self.mission_req_table[mission]._asdict() slot_data["mission_req"] = slot_req_table + slot_data["final_mission"] = self.final_mission_id return slot_data @@ -120,30 +121,37 @@ def get_excluded_items(self: SC2WoLWorld, world: MultiWorld, player: int) -> Set for item in world.precollected_items[player]: excluded_items.add(item.name) + excluded_items_option = getattr(world, 'excluded_items', []) + + excluded_items.update(excluded_items_option[player].value) + return excluded_items -def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str]): +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 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") - local_basic_unit = tuple(item for item in basic_unit 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": + first_location = first_mission + ": Defeat" + else: + first_location = first_mission + ": Victory" - # The first world should also be the starting world - first_location = list(world.worlds[player].mission_req_table)[0] - - if first_location == "In Utter Darkness": - first_location = first_location + ": Defeat" + return [assign_starter_item(world, player, excluded_items, locked_locations, first_location, local_basic_unit)] else: - first_location = first_location + ": Victory" - - assign_starter_item(world, player, excluded_items, locked_locations, first_location, - local_basic_unit) + return [] def assign_starter_item(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str], - location: str, item_list: Tuple[str, ...]): + location: str, item_list: Tuple[str, ...]) -> Item: item_name = world.random.choice(item_list) @@ -155,17 +163,40 @@ def assign_starter_item(world: MultiWorld, player: int, excluded_items: Set[str] locked_locations.append(location) + return item -def get_item_pool(world: MultiWorld, player: int, excluded_items: Set[str]) -> List[Item]: + +def get_item_pool(world: MultiWorld, player: int, mission_req_table: Dict[str, MissionInfo], + starter_items: List[str], excluded_items: Set[str], location_cache: List[Location]) -> List[Item]: pool: List[Item] = [] + # For the future: goal items like Artifact Shards go here + locked_items = [] + + # YAML items + yaml_locked_items = get_option_set_value(world, player, 'locked_items') + for name, data in item_table.items(): if name not in excluded_items: for _ in range(data.quantity): item = create_item_with_correct_settings(world, player, name) - pool.append(item) + if name in yaml_locked_items: + locked_items.append(item) + else: + pool.append(item) - return pool + existing_items = starter_items + [item 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: + 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 def fill_item_pool_with_dummy_items(self: SC2WoLWorld, world: MultiWorld, player: int, locked_locations: List[str],