SC2: Greater variety on short generations (#1367)

Originally, short generations used an artificial cull to create balanced mission distributions. This resulted in campaigns that were somewhat too consistent, and on some standard settings combinations, this resulted in campaigns having The Outlaws as the second mission 100% of the time. It also caused generation to fail a bit too easily if the player excluded too many missions.

This removes the cull and adds an additional early Easy mission slot to all of the reduced sized campaigns.

When playing on No Build settings, this also pushes many of the missions down a difficulty level to ensure greater variety, and pushes additional missions down on Advanced Tactics.

Additional small fixes:

The in-world Excluded Missions validation check is replaced by the core OptionSet check.
Fixed issue with Existing Items not getting their upgrades locked with Units Always Have Upgrades on.
This commit is contained in:
Magnemania 2023-03-07 08:14:49 -05:00 committed by GitHub
parent 016157a0eb
commit 17e90ce12c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 273 additions and 264 deletions

View File

@ -52,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
options = difficulty.split() options = difficulty.split()
num_options = len(options) num_options = len(options)
difficulty_choice = options[0].lower()
if num_options > 0: if num_options > 0:
difficulty_choice = options[0].lower()
if difficulty_choice == "casual": if difficulty_choice == "casual":
self.ctx.difficulty_override = 0 self.ctx.difficulty_override = 0
elif difficulty_choice == "normal": elif difficulty_choice == "normal":
@ -71,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
return True return True
else: else:
self.output("Difficulty needs to be specified in the command.") if self.ctx.difficulty == -1:
self.output("Please connect to a seed before checking difficulty.")
else:
self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty])
self.output("To change the difficulty, add the name of the difficulty after the command.")
return False return False
def _cmd_disable_mission_check(self) -> bool: def _cmd_disable_mission_check(self) -> bool:

View File

@ -182,9 +182,11 @@ filler_items: typing.Tuple[str, ...] = (
'+15 Starting Vespene' '+15 Starting Vespene'
) )
# Defense rating table
# Commented defense ratings are handled in LogicMixin
defense_ratings = { defense_ratings = {
"Siege Tank": 5, "Siege Tank": 5,
"Maelstrom Rounds": 2, # "Maelstrom Rounds": 2,
"Planetary Fortress": 3, "Planetary Fortress": 3,
# Bunker w/ Marine/Marauder: 3, # Bunker w/ Marine/Marauder: 3,
"Perdition Turret": 2, "Perdition Turret": 2,
@ -193,7 +195,7 @@ defense_ratings = {
} }
zerg_defense_ratings = { zerg_defense_ratings = {
"Perdition Turret": 2, "Perdition Turret": 2,
# Bunker w/ Firebat: 2 # Bunker w/ Firebat: 2,
"Hive Mind Emulator": 3, "Hive Mind Emulator": 3,
"Psi Disruptor": 3 "Psi Disruptor": 3
} }

View File

@ -17,10 +17,12 @@ class SC2WoLLogic(LogicMixin):
or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('Wraith', player) or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has('Wraith', player)
def _sc2wol_has_competent_anti_air(self, multiworld: MultiWorld, player: int) -> bool: def _sc2wol_has_competent_anti_air(self, multiworld: MultiWorld, player: int) -> bool:
return self.has_any({'Marine', 'Goliath'}, player) or self._sc2wol_has_air_anti_air(multiworld, player) return self.has('Goliath', player) \
or self.has('Marine', player) and self.has_any({'Medic', 'Medivac'}, player) \
or self._sc2wol_has_air_anti_air(multiworld, player)
def _sc2wol_has_anti_air(self, multiworld: MultiWorld, player: int) -> bool: def _sc2wol_has_anti_air(self, multiworld: MultiWorld, player: int) -> bool:
return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Wraith'}, player) \ return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Marine', 'Wraith'}, player) \
or self._sc2wol_has_competent_anti_air(multiworld, player) \ or self._sc2wol_has_competent_anti_air(multiworld, player) \
or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) or get_option_value(multiworld, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player)
@ -28,6 +30,8 @@ class SC2WoLLogic(LogicMixin):
defense_score = sum((defense_ratings[item] for item in defense_ratings if self.has(item, player))) 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): if self.has_any({'Marine', 'Marauder'}, player) and self.has('Bunker', player):
defense_score += 3 defense_score += 3
if self.has_all({'Siege Tank', 'Maelstrom Rounds'}, player):
defense_score += 2
if zerg_enemy: if zerg_enemy:
defense_score += sum((zerg_defense_ratings[item] for item in zerg_defense_ratings if self.has(item, player))) 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): if self.has('Firebat', player) and self.has('Bunker', player):

View File

@ -1,7 +1,5 @@
from typing import NamedTuple, Dict, List, Set from typing import NamedTuple, Dict, List
from enum import IntEnum
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", no_build_regions_list = ["Liberation Day", "Breakout", "Ghost of a Chance", "Piercing the Shroud", "Whispers of Doom",
"Belly of the Beast"] "Belly of the Beast"]
@ -13,6 +11,14 @@ hard_regions_list = ["Maw of the Void", "Engine of Destruction", "In Utter Darkn
"Shatter the Sky"] "Shatter the Sky"]
class MissionPools(IntEnum):
STARTER = 0
EASY = 1
MEDIUM = 2
HARD = 3
FINAL = 4
class MissionInfo(NamedTuple): class MissionInfo(NamedTuple):
id: int id: int
required_world: List[int] required_world: List[int]
@ -23,119 +29,119 @@ class MissionInfo(NamedTuple):
class FillMission(NamedTuple): class FillMission(NamedTuple):
type: str type: int
connect_to: List[int] # -1 connects to Menu connect_to: List[int] # -1 connects to Menu
category: str category: str
number: int = 0 # number of worlds need beaten number: int = 0 # number of worlds need beaten
completion_critical: bool = False # missions needed to beat game completion_critical: bool = False # missions needed to beat game
or_requirements: bool = False # true if the requirements should be or-ed instead of and-ed or_requirements: bool = False # true if the requirements should be or-ed instead of and-ed
relegate: bool = False # true if this is a slot no build missions should be relegated to. removal_priority: int = 0 # how many missions missing from the pool required to remove this mission
vanilla_shuffle_order = [ vanilla_shuffle_order = [
FillMission("no_build", [-1], "Mar Sara", completion_critical=True), FillMission(MissionPools.STARTER, [-1], "Mar Sara", completion_critical=True),
FillMission("easy", [0], "Mar Sara", completion_critical=True), FillMission(MissionPools.EASY, [0], "Mar Sara", completion_critical=True),
FillMission("easy", [1], "Mar Sara", completion_critical=True), FillMission(MissionPools.EASY, [1], "Mar Sara", completion_critical=True),
FillMission("easy", [2], "Colonist"), FillMission(MissionPools.EASY, [2], "Colonist"),
FillMission("medium", [3], "Colonist"), FillMission(MissionPools.MEDIUM, [3], "Colonist"),
FillMission("hard", [4], "Colonist", number=7), FillMission(MissionPools.HARD, [4], "Colonist", number=7),
FillMission("hard", [4], "Colonist", number=7, relegate=True), FillMission(MissionPools.HARD, [4], "Colonist", number=7, removal_priority=1),
FillMission("easy", [2], "Artifact", completion_critical=True), FillMission(MissionPools.EASY, [2], "Artifact", completion_critical=True),
FillMission("medium", [7], "Artifact", number=8, completion_critical=True), FillMission(MissionPools.MEDIUM, [7], "Artifact", number=8, completion_critical=True),
FillMission("hard", [8], "Artifact", number=11, completion_critical=True), FillMission(MissionPools.HARD, [8], "Artifact", number=11, completion_critical=True),
FillMission("hard", [9], "Artifact", number=14, completion_critical=True), FillMission(MissionPools.HARD, [9], "Artifact", number=14, completion_critical=True),
FillMission("hard", [10], "Artifact", completion_critical=True), FillMission(MissionPools.HARD, [10], "Artifact", completion_critical=True),
FillMission("medium", [2], "Covert", number=4), FillMission(MissionPools.MEDIUM, [2], "Covert", number=4),
FillMission("medium", [12], "Covert"), FillMission(MissionPools.MEDIUM, [12], "Covert"),
FillMission("hard", [13], "Covert", number=8, relegate=True), FillMission(MissionPools.HARD, [13], "Covert", number=8, removal_priority=3),
FillMission("hard", [13], "Covert", number=8, relegate=True), FillMission(MissionPools.HARD, [13], "Covert", number=8, removal_priority=2),
FillMission("medium", [2], "Rebellion", number=6), FillMission(MissionPools.MEDIUM, [2], "Rebellion", number=6),
FillMission("hard", [16], "Rebellion"), FillMission(MissionPools.HARD, [16], "Rebellion"),
FillMission("hard", [17], "Rebellion"), FillMission(MissionPools.HARD, [17], "Rebellion"),
FillMission("hard", [18], "Rebellion"), FillMission(MissionPools.HARD, [18], "Rebellion"),
FillMission("hard", [19], "Rebellion", relegate=True), FillMission(MissionPools.HARD, [19], "Rebellion", removal_priority=5),
FillMission("medium", [8], "Prophecy"), FillMission(MissionPools.MEDIUM, [8], "Prophecy", removal_priority=9),
FillMission("hard", [21], "Prophecy"), FillMission(MissionPools.HARD, [21], "Prophecy", removal_priority=8),
FillMission("hard", [22], "Prophecy"), FillMission(MissionPools.HARD, [22], "Prophecy", removal_priority=7),
FillMission("hard", [23], "Prophecy", relegate=True), FillMission(MissionPools.HARD, [23], "Prophecy", removal_priority=6),
FillMission("hard", [11], "Char", completion_critical=True), FillMission(MissionPools.HARD, [11], "Char", completion_critical=True),
FillMission("hard", [25], "Char", completion_critical=True), FillMission(MissionPools.HARD, [25], "Char", completion_critical=True, removal_priority=4),
FillMission("hard", [25], "Char", completion_critical=True), FillMission(MissionPools.HARD, [25], "Char", completion_critical=True),
FillMission("all_in", [26, 27], "Char", completion_critical=True, or_requirements=True) FillMission(MissionPools.FINAL, [26, 27], "Char", completion_critical=True, or_requirements=True)
] ]
mini_campaign_order = [ mini_campaign_order = [
FillMission("no_build", [-1], "Mar Sara", completion_critical=True), FillMission(MissionPools.STARTER, [-1], "Mar Sara", completion_critical=True),
FillMission("easy", [0], "Colonist"), FillMission(MissionPools.EASY, [0], "Colonist"),
FillMission("medium", [1], "Colonist"), FillMission(MissionPools.MEDIUM, [1], "Colonist"),
FillMission("medium", [0], "Artifact", completion_critical=True), FillMission(MissionPools.EASY, [0], "Artifact", completion_critical=True),
FillMission("medium", [3], "Artifact", number=4, completion_critical=True), FillMission(MissionPools.MEDIUM, [3], "Artifact", number=4, completion_critical=True),
FillMission("hard", [4], "Artifact", number=8, completion_critical=True), FillMission(MissionPools.HARD, [4], "Artifact", number=8, completion_critical=True),
FillMission("medium", [0], "Covert", number=2), FillMission(MissionPools.MEDIUM, [0], "Covert", number=2),
FillMission("hard", [6], "Covert"), FillMission(MissionPools.HARD, [6], "Covert"),
FillMission("medium", [0], "Rebellion", number=3), FillMission(MissionPools.MEDIUM, [0], "Rebellion", number=3),
FillMission("hard", [8], "Rebellion"), FillMission(MissionPools.HARD, [8], "Rebellion"),
FillMission("medium", [4], "Prophecy"), FillMission(MissionPools.MEDIUM, [4], "Prophecy"),
FillMission("hard", [10], "Prophecy"), FillMission(MissionPools.HARD, [10], "Prophecy"),
FillMission("hard", [5], "Char", completion_critical=True), FillMission(MissionPools.HARD, [5], "Char", completion_critical=True),
FillMission("hard", [5], "Char", completion_critical=True), FillMission(MissionPools.HARD, [5], "Char", completion_critical=True),
FillMission("all_in", [12, 13], "Char", completion_critical=True, or_requirements=True) FillMission(MissionPools.FINAL, [12, 13], "Char", completion_critical=True, or_requirements=True)
] ]
gauntlet_order = [ gauntlet_order = [
FillMission("no_build", [-1], "I", completion_critical=True), FillMission(MissionPools.STARTER, [-1], "I", completion_critical=True),
FillMission("easy", [0], "II", completion_critical=True), FillMission(MissionPools.EASY, [0], "II", completion_critical=True),
FillMission("medium", [1], "III", completion_critical=True), FillMission(MissionPools.EASY, [1], "III", completion_critical=True),
FillMission("medium", [2], "IV", completion_critical=True), FillMission(MissionPools.MEDIUM, [2], "IV", completion_critical=True),
FillMission("hard", [3], "V", completion_critical=True), FillMission(MissionPools.MEDIUM, [3], "V", completion_critical=True),
FillMission("hard", [4], "VI", completion_critical=True), FillMission(MissionPools.HARD, [4], "VI", completion_critical=True),
FillMission("all_in", [5], "Final", completion_critical=True) FillMission(MissionPools.FINAL, [5], "Final", completion_critical=True)
] ]
grid_order = [ grid_order = [
FillMission("no_build", [-1], "_1"), FillMission(MissionPools.STARTER, [-1], "_1"),
FillMission("medium", [0], "_1"), FillMission(MissionPools.EASY, [0], "_1"),
FillMission("medium", [1, 6, 3], "_1", or_requirements=True), FillMission(MissionPools.MEDIUM, [1, 6, 3], "_1", or_requirements=True),
FillMission("hard", [2, 7], "_1", or_requirements=True), FillMission(MissionPools.HARD, [2, 7], "_1", or_requirements=True),
FillMission("easy", [0], "_2"), FillMission(MissionPools.EASY, [0], "_2"),
FillMission("medium", [1, 4], "_2", or_requirements=True), FillMission(MissionPools.MEDIUM, [1, 4], "_2", or_requirements=True),
FillMission("hard", [2, 5, 10, 7], "_2", or_requirements=True), FillMission(MissionPools.HARD, [2, 5, 10, 7], "_2", or_requirements=True),
FillMission("hard", [3, 6, 11], "_2", or_requirements=True), FillMission(MissionPools.HARD, [3, 6, 11], "_2", or_requirements=True),
FillMission("medium", [4, 9, 12], "_3", or_requirements=True), FillMission(MissionPools.MEDIUM, [4, 9, 12], "_3", or_requirements=True),
FillMission("hard", [5, 8, 10, 13], "_3", or_requirements=True), FillMission(MissionPools.HARD, [5, 8, 10, 13], "_3", or_requirements=True),
FillMission("hard", [6, 9, 11, 14], "_3", or_requirements=True), FillMission(MissionPools.HARD, [6, 9, 11, 14], "_3", or_requirements=True),
FillMission("hard", [7, 10], "_3", or_requirements=True), FillMission(MissionPools.HARD, [7, 10], "_3", or_requirements=True),
FillMission("hard", [8, 13], "_4", or_requirements=True), FillMission(MissionPools.HARD, [8, 13], "_4", or_requirements=True),
FillMission("hard", [9, 12, 14], "_4", or_requirements=True), FillMission(MissionPools.HARD, [9, 12, 14], "_4", or_requirements=True),
FillMission("hard", [10, 13], "_4", or_requirements=True), FillMission(MissionPools.HARD, [10, 13], "_4", or_requirements=True),
FillMission("all_in", [11, 14], "_4", or_requirements=True) FillMission(MissionPools.FINAL, [11, 14], "_4", or_requirements=True)
] ]
mini_grid_order = [ mini_grid_order = [
FillMission("no_build", [-1], "_1"), FillMission(MissionPools.STARTER, [-1], "_1"),
FillMission("medium", [0], "_1"), FillMission(MissionPools.EASY, [0], "_1"),
FillMission("medium", [1, 5], "_1", or_requirements=True), FillMission(MissionPools.MEDIUM, [1, 5], "_1", or_requirements=True),
FillMission("easy", [0], "_2"), FillMission(MissionPools.EASY, [0], "_2"),
FillMission("medium", [1, 3], "_2", or_requirements=True), FillMission(MissionPools.MEDIUM, [1, 3], "_2", or_requirements=True),
FillMission("hard", [2, 4], "_2", or_requirements=True), FillMission(MissionPools.HARD, [2, 4], "_2", or_requirements=True),
FillMission("medium", [3, 7], "_3", or_requirements=True), FillMission(MissionPools.MEDIUM, [3, 7], "_3", or_requirements=True),
FillMission("hard", [4, 6], "_3", or_requirements=True), FillMission(MissionPools.HARD, [4, 6], "_3", or_requirements=True),
FillMission("all_in", [5, 7], "_3", or_requirements=True) FillMission(MissionPools.FINAL, [5, 7], "_3", or_requirements=True)
] ]
blitz_order = [ blitz_order = [
FillMission("no_build", [-1], "I"), FillMission(MissionPools.STARTER, [-1], "I"),
FillMission("easy", [-1], "I"), FillMission(MissionPools.EASY, [-1], "I"),
FillMission("medium", [0, 1], "II", number=1, or_requirements=True), FillMission(MissionPools.MEDIUM, [0, 1], "II", number=1, or_requirements=True),
FillMission("medium", [0, 1], "II", number=1, or_requirements=True), FillMission(MissionPools.MEDIUM, [0, 1], "II", number=1, or_requirements=True),
FillMission("medium", [0, 1], "III", number=2, or_requirements=True), FillMission(MissionPools.MEDIUM, [0, 1], "III", number=2, or_requirements=True),
FillMission("medium", [0, 1], "III", number=2, or_requirements=True), FillMission(MissionPools.MEDIUM, [0, 1], "III", number=2, or_requirements=True),
FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), FillMission(MissionPools.HARD, [0, 1], "IV", number=3, or_requirements=True),
FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), FillMission(MissionPools.HARD, [0, 1], "IV", number=3, or_requirements=True),
FillMission("hard", [0, 1], "V", number=4, or_requirements=True), FillMission(MissionPools.HARD, [0, 1], "V", number=4, or_requirements=True),
FillMission("hard", [0, 1], "V", number=4, or_requirements=True), FillMission(MissionPools.HARD, [0, 1], "V", number=4, or_requirements=True),
FillMission("hard", [0, 1], "Final", number=5, or_requirements=True), FillMission(MissionPools.HARD, [0, 1], "Final", number=5, or_requirements=True),
FillMission("all_in", [0, 1], "Final", number=5, or_requirements=True) FillMission(MissionPools.FINAL, [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] mission_orders = [vanilla_shuffle_order, vanilla_shuffle_order, mini_campaign_order, grid_order, mini_grid_order, blitz_order, gauntlet_order]
@ -176,40 +182,21 @@ vanilla_mission_req_table = {
lookup_id_to_mission: Dict[int, str] = { lookup_id_to_mission: Dict[int, str] = {
data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id}
no_build_starting_mission_locations = { starting_mission_locations = {
"Liberation Day": "Liberation Day: Victory", "Liberation Day": "Liberation Day: Victory",
"Breakout": "Breakout: Victory", "Breakout": "Breakout: Victory",
"Ghost of a Chance": "Ghost of a Chance: Victory", "Ghost of a Chance": "Ghost of a Chance: Victory",
"Piercing the Shroud": "Piercing the Shroud: Victory", "Piercing the Shroud": "Piercing the Shroud: Victory",
"Whispers of Doom": "Whispers of Doom: Victory", "Whispers of Doom": "Whispers of Doom: Victory",
"Belly of the Beast": "Belly of the Beast: Victory", "Belly of the Beast": "Belly of the Beast: Victory",
}
build_starting_mission_locations = {
"Zero Hour": "Zero Hour: First Group Rescued", "Zero Hour": "Zero Hour: First Group Rescued",
"Evacuation": "Evacuation: First Chysalis", "Evacuation": "Evacuation: First Chysalis",
"Devil's Playground": "Devil's Playground: Tosh's Miners" "Devil's Playground": "Devil's Playground: Tosh's Miners",
}
advanced_starting_mission_locations = {
"Smash and Grab": "Smash and Grab: First Relic", "Smash and Grab": "Smash and Grab: First Relic",
"The Great Train Robbery": "The Great Train Robbery: North Defiler" "The Great Train Robbery": "The Great Train Robbery: North Defiler"
} }
def get_starting_mission_locations(multiworld: MultiWorld, player: int) -> Set[str]:
if get_option_value(multiworld, player, 'shuffle_no_build') or get_option_value(multiworld, 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(multiworld, 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 = { alt_final_mission_locations = {
"Maw of the Void": "Maw of the Void: Victory", "Maw of the Void": "Maw of the Void: Victory",
"Engine of Destruction": "Engine of Destruction: Victory", "Engine of Destruction": "Engine of Destruction: Victory",

View File

@ -1,6 +1,7 @@
from typing import Dict from typing import Dict, FrozenSet, Union
from BaseClasses import MultiWorld from BaseClasses import MultiWorld
from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range
from .MissionTables import vanilla_mission_req_table
class GameDifficulty(Choice): class GameDifficulty(Choice):
@ -110,6 +111,7 @@ class ExcludedMissions(OptionSet):
Only applies on shortened mission orders. Only applies on shortened mission orders.
It may be impossible to build a valid campaign if too many missions are excluded.""" It may be impossible to build a valid campaign if too many missions are excluded."""
display_name = "Excluded Missions" display_name = "Excluded Missions"
valid_keys = {mission_name for mission_name in vanilla_mission_req_table.keys() if mission_name != 'All-In'}
# noinspection PyTypeChecker # noinspection PyTypeChecker
@ -130,19 +132,10 @@ sc2wol_options: Dict[str, Option] = {
} }
def get_option_value(multiworld: MultiWorld, player: int, name: str) -> int: def get_option_value(multiworld: MultiWorld, player: int, name: str) -> Union[int, FrozenSet]:
option = getattr(multiworld, name, None) if multiworld is None:
return sc2wol_options[name].default
if option is None: player_option = getattr(multiworld, name)[player]
return 0
return int(option[player].value) return player_option.value
def get_option_set_value(multiworld: MultiWorld, player: int, name: str) -> set:
option = getattr(multiworld, name, None)
if option is None:
return set()
return option[player].value

View File

@ -2,8 +2,8 @@ from typing import Callable, Dict, List, Set
from BaseClasses import MultiWorld, ItemClassification, Item, Location from BaseClasses import MultiWorld, ItemClassification, Item, Location
from .Items import item_table from .Items import item_table
from .MissionTables import no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list,\ 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 mission_orders, MissionInfo, alt_final_mission_locations, MissionPools
from .Options import get_option_value, get_option_set_value from .Options import get_option_value
from .LogicMixin import SC2WoLLogic from .LogicMixin import SC2WoLLogic
# Items with associated upgrades # Items with associated upgrades
@ -21,34 +21,33 @@ STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "He
PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"}
def filter_missions(multiworld: MultiWorld, player: int) -> Dict[str, List[str]]: def filter_missions(multiworld: MultiWorld, player: int) -> Dict[int, List[str]]:
""" """
Returns a semi-randomly pruned tuple of no-build, easy, medium, and hard mission sets Returns a semi-randomly pruned tuple of no-build, easy, medium, and hard mission sets
""" """
mission_order_type = get_option_value(multiworld, player, "mission_order") mission_order_type = get_option_value(multiworld, player, "mission_order")
shuffle_no_build = get_option_value(multiworld, player, "shuffle_no_build")
shuffle_protoss = get_option_value(multiworld, player, "shuffle_protoss") shuffle_protoss = get_option_value(multiworld, player, "shuffle_protoss")
excluded_missions = set(get_option_set_value(multiworld, player, "excluded_missions")) excluded_missions = get_option_value(multiworld, 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 mission_count = len(mission_orders[mission_order_type]) - 1
# Vanilla and Vanilla Shuffled use the entire mission pool mission_pools = {
if mission_count == 28: MissionPools.STARTER: no_build_regions_list[:],
return { MissionPools.EASY: easy_regions_list[:],
"no_build": no_build_regions_list[:], MissionPools.MEDIUM: medium_regions_list[:],
"easy": easy_regions_list[:], MissionPools.HARD: hard_regions_list[:],
"medium": medium_regions_list[:], MissionPools.FINAL: []
"hard": hard_regions_list[:], }
"all_in": ["All-In"] if mission_order_type == 0:
} # Vanilla uses the entire mission pool
mission_pools[MissionPools.FINAL] = ['All-In']
mission_pools = [ return mission_pools
[], elif mission_order_type == 1:
easy_regions_list, # Vanilla Shuffled ignores the player-provided excluded missions
medium_regions_list, excluded_missions = set()
hard_regions_list # Omitting No-Build missions if not shuffling no-build
] if not shuffle_no_build:
excluded_missions = excluded_missions.union(no_build_regions_list)
# Omitting Protoss missions if not shuffling protoss # Omitting Protoss missions if not shuffling protoss
if not shuffle_protoss: if not shuffle_protoss:
excluded_missions = excluded_missions.union(PROTOSS_REGIONS) excluded_missions = excluded_missions.union(PROTOSS_REGIONS)
@ -58,46 +57,35 @@ def filter_missions(multiworld: MultiWorld, player: int) -> Dict[str, List[str]]
excluded_missions.add(final_mission) excluded_missions.add(final_mission)
else: else:
final_mission = 'All-In' final_mission = 'All-In'
# Yaml settings determine which missions can be placed in the first slot # Excluding missions
mission_pools[0] = [mission for mission in get_starting_mission_locations(multiworld, player).keys() if mission not in excluded_missions] for difficulty, mission_pool in mission_pools.items():
# Removing the new no-build missions from their original sets mission_pools[difficulty] = [mission for mission in mission_pool if mission not in excluded_missions]
for i in range(1, len(mission_pools)): mission_pools[MissionPools.FINAL].append(final_mission)
mission_pools[i] = [mission for mission in mission_pools[i] if mission not in excluded_missions.union(mission_pools[0])] # Mission pool changes on Build-Only
# 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(multiworld, player, 'shuffle_no_build'): if not get_option_value(multiworld, player, 'shuffle_no_build'):
# Swapping Outbreak and The Great Train Robbery def move_mission(mission_name, current_pool, new_pool):
if "Outbreak" in mission_pools[1]: if mission_name in mission_pools[current_pool]:
mission_pools[1].remove("Outbreak") mission_pools[current_pool].remove(mission_name)
mission_pools[2].append("Outbreak") mission_pools[new_pool].append(mission_name)
if "The Great Train Robbery" in mission_pools[2]: # Replacing No Build missions with Easy missions
mission_pools[2].remove("The Great Train Robbery") move_mission("Zero Hour", MissionPools.EASY, MissionPools.STARTER)
mission_pools[1].append("The Great Train Robbery") move_mission("Evacuation", MissionPools.EASY, MissionPools.STARTER)
# Removing random missions from each difficulty set in a cycle move_mission("Devil's Playground", MissionPools.EASY, MissionPools.STARTER)
set_cycle = 0 # Pushing Outbreak to Normal, as it cannot be placed as the second mission on Build-Only
current_count = sum(len(mission_pool) for mission_pool in mission_pools) move_mission("Outbreak", MissionPools.EASY, MissionPools.MEDIUM)
# Pushing extra Normal missions to Easy
move_mission("The Great Train Robbery", MissionPools.MEDIUM, MissionPools.EASY)
move_mission("Echoes of the Future", MissionPools.MEDIUM, MissionPools.EASY)
move_mission("Cutthroat", MissionPools.MEDIUM, MissionPools.EASY)
# Additional changes on Advanced Tactics
if get_option_value(multiworld, player, "required_tactics") > 0:
move_mission("The Great Train Robbery", MissionPools.EASY, MissionPools.STARTER)
move_mission("Smash and Grab", MissionPools.EASY, MissionPools.STARTER)
move_mission("Moebius Factor", MissionPools.MEDIUM, MissionPools.EASY)
move_mission("Welcome to the Jungle", MissionPools.MEDIUM, MissionPools.EASY)
move_mission("Engine of Destruction", MissionPools.HARD, MissionPools.MEDIUM)
if current_count < mission_count: return mission_pools
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(multiworld.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): def get_item_upgrades(inventory: List[Item], parent_item: Item or str):
@ -135,7 +123,21 @@ class ValidInventory:
requirements = mission_requirements requirements = mission_requirements
cascade_keys = self.cascade_removal_map.keys() cascade_keys = self.cascade_removal_map.keys()
units_always_have_upgrades = get_option_value(self.multiworld, self.player, "units_always_have_upgrades") units_always_have_upgrades = get_option_value(self.multiworld, self.player, "units_always_have_upgrades")
if self.min_units_per_structure > 0:
# Locking associated items for items that have already been placed when units_always_have_upgrades is on
if units_always_have_upgrades:
existing_items = self.existing_items[:]
while existing_items:
existing_item = existing_items.pop()
items_to_lock = self.cascade_removal_map.get(existing_item, [existing_item])
for item in items_to_lock:
if item in inventory:
inventory.remove(item)
locked_items.append(item)
if item in existing_items:
existing_items.remove(item)
if self.min_units_per_structure > 0 and self.has_units_per_structure():
requirements.append(lambda state: state.has_units_per_structure()) requirements.append(lambda state: state.has_units_per_structure())
def attempt_removal(item: Item) -> bool: def attempt_removal(item: Item) -> bool:
@ -151,6 +153,10 @@ class ValidInventory:
return False return False
return True return True
# Determining if the full-size inventory can complete campaign
if not all(requirement(self) for requirement in requirements):
raise Exception("Too many items excluded - campaign is impossible to complete.")
while len(inventory) + len(locked_items) > inventory_size: while len(inventory) + len(locked_items) > inventory_size:
if len(inventory) == 0: if len(inventory) == 0:
raise Exception("Reduced item pool generation failed - not enough locations available to place items.") raise Exception("Reduced item pool generation failed - not enough locations available to place items.")

View File

@ -2,7 +2,7 @@ from typing import List, Set, Dict, Tuple, Optional, Callable
from BaseClasses import MultiWorld, Region, Entrance, Location from BaseClasses import MultiWorld, Region, Entrance, Location
from .Locations import LocationData from .Locations import LocationData
from .Options import get_option_value from .Options import get_option_value
from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations, MissionPools
from .PoolFilter import filter_missions from .PoolFilter import filter_missions
@ -14,34 +14,18 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio
mission_order = mission_orders[mission_order_type] mission_order = mission_orders[mission_order_type]
mission_pools = filter_missions(multiworld, player) mission_pools = filter_missions(multiworld, 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(multiworld, player, locations_per_region, location_cache, "Menu")] regions = [create_region(multiworld, player, locations_per_region, location_cache, "Menu")]
for region_name in used_regions:
regions.append(create_region(multiworld, 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__:
if mission_order_type in (0, 1):
throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys())
multiworld.regions += regions
names: Dict[str, int] = {} names: Dict[str, int] = {}
if mission_order_type == 0: if mission_order_type == 0:
# Generating all regions and locations
for region_name in vanilla_mission_req_table.keys():
regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name))
multiworld.regions += regions
connect(multiworld, player, names, 'Menu', 'Liberation Day'), connect(multiworld, player, names, 'Menu', 'Liberation Day'),
connect(multiworld, player, names, 'Liberation Day', 'The Outlaws', connect(multiworld, player, names, 'Liberation Day', 'The Outlaws',
lambda state: state.has("Beat Liberation Day", player)), lambda state: state.has("Beat Liberation Day", player)),
@ -110,31 +94,32 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio
lambda state: state.has('Beat Gates of Hell', player) and ( 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))) state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player)))
return vanilla_mission_req_table, 29, final_location return vanilla_mission_req_table, 29, 'All-In: Victory'
else: else:
missions = [] missions = []
remove_prophecy = mission_order_type == 1 and not get_option_value(multiworld, player, "shuffle_protoss")
final_mission = mission_pools[MissionPools.FINAL][0]
# Determining if missions must be removed
mission_pool_size = sum(len(mission_pool) for mission_pool in mission_pools.values())
removals = len(mission_order) - mission_pool_size
# Removing entire Prophecy chain on vanilla shuffled when not shuffling protoss
if remove_prophecy:
removals -= 4
# Initial fill out of mission list and marking all-in mission # Initial fill out of mission list and marking all-in mission
for mission in mission_order: for mission in mission_order:
if mission is None: # Removing extra missions if mission pool is too small
if 0 < mission.removal_priority <= removals or mission.category == 'Prophecy' and remove_prophecy:
missions.append(None) missions.append(None)
elif mission.type == "all_in": elif mission.type == MissionPools.FINAL:
missions.append(final_mission) missions.append(final_mission)
elif mission.relegate and not get_option_value(multiworld, player, "shuffle_no_build"):
missions.append("no_build")
else: else:
missions.append(mission.type) missions.append(mission.type)
# Place Protoss Missions if we are not using ShuffleProtoss and are in Vanilla Shuffled
if get_option_value(multiworld, player, "shuffle_protoss") == 0 and mission_order_type == 1:
missions[22] = "A Sinister Turn"
mission_pools['medium'].remove("A Sinister Turn")
missions[23] = "Echoes of the Future"
mission_pools['medium'].remove("Echoes of the Future")
missions[24] = "In Utter Darkness"
mission_pools['hard'].remove("In Utter Darkness")
no_build_slots = [] no_build_slots = []
easy_slots = [] easy_slots = []
medium_slots = [] medium_slots = []
@ -144,79 +129,108 @@ def create_regions(multiworld: MultiWorld, player: int, locations: Tuple[Locatio
for i in range(len(missions)): for i in range(len(missions)):
if missions[i] is None: if missions[i] is None:
continue continue
if missions[i] == "no_build": if missions[i] == MissionPools.STARTER:
no_build_slots.append(i) no_build_slots.append(i)
elif missions[i] == "easy": elif missions[i] == MissionPools.EASY:
easy_slots.append(i) easy_slots.append(i)
elif missions[i] == "medium": elif missions[i] == MissionPools.MEDIUM:
medium_slots.append(i) medium_slots.append(i)
elif missions[i] == "hard": elif missions[i] == MissionPools.HARD:
hard_slots.append(i) hard_slots.append(i)
# Add no_build missions to the pool and fill in no_build slots # Add no_build missions to the pool and fill in no_build slots
missions_to_add = mission_pools['no_build'] missions_to_add = mission_pools[MissionPools.STARTER]
if len(no_build_slots) > len(missions_to_add):
raise Exception("There are no valid No-Build missions. Please exclude fewer missions.")
for slot in no_build_slots: for slot in no_build_slots:
filler = multiworld.random.randint(0, len(missions_to_add) - 1) filler = multiworld.random.randint(0, len(missions_to_add) - 1)
missions[slot] = missions_to_add.pop(filler) missions[slot] = missions_to_add.pop(filler)
# Add easy missions into pool and fill in easy slots # Add easy missions into pool and fill in easy slots
missions_to_add = missions_to_add + mission_pools['easy'] missions_to_add = missions_to_add + mission_pools[MissionPools.EASY]
if len(easy_slots) > len(missions_to_add):
raise Exception("There are not enough Easy missions to fill the campaign. Please exclude fewer missions.")
for slot in easy_slots: for slot in easy_slots:
filler = multiworld.random.randint(0, len(missions_to_add) - 1) filler = multiworld.random.randint(0, len(missions_to_add) - 1)
missions[slot] = missions_to_add.pop(filler) missions[slot] = missions_to_add.pop(filler)
# Add medium missions into pool and fill in medium slots # Add medium missions into pool and fill in medium slots
missions_to_add = missions_to_add + mission_pools['medium'] missions_to_add = missions_to_add + mission_pools[MissionPools.MEDIUM]
if len(medium_slots) > len(missions_to_add):
raise Exception("There are not enough Easy and Medium missions to fill the campaign. Please exclude fewer missions.")
for slot in medium_slots: for slot in medium_slots:
filler = multiworld.random.randint(0, len(missions_to_add) - 1) filler = multiworld.random.randint(0, len(missions_to_add) - 1)
missions[slot] = missions_to_add.pop(filler) missions[slot] = missions_to_add.pop(filler)
# Add hard missions into pool and fill in hard slots # Add hard missions into pool and fill in hard slots
missions_to_add = missions_to_add + mission_pools['hard'] missions_to_add = missions_to_add + mission_pools[MissionPools.HARD]
if len(hard_slots) > len(missions_to_add):
raise Exception("There are not enough missions to fill the campaign. Please exclude fewer missions.")
for slot in hard_slots: for slot in hard_slots:
filler = multiworld.random.randint(0, len(missions_to_add) - 1) filler = multiworld.random.randint(0, len(missions_to_add) - 1)
missions[slot] = missions_to_add.pop(filler) missions[slot] = missions_to_add.pop(filler)
# Generating regions and locations from selected missions
for region_name in missions:
regions.append(create_region(multiworld, player, locations_per_region, location_cache, region_name))
multiworld.regions += regions
# Mapping original mission slots to shifted mission slots when missions are removed
slot_map = []
slot_offset = 0
for position, mission in enumerate(missions):
slot_map.append(position - slot_offset + 1)
if mission is None:
slot_offset += 1
# Loop through missions to create requirements table and connect regions # Loop through missions to create requirements table and connect regions
# TODO: Handle 'and' connections # TODO: Handle 'and' connections
mission_req_table = {} mission_req_table = {}
for i in range(len(missions)):
for i, mission in enumerate(missions):
if mission is None:
continue
connections = [] connections = []
for connection in mission_order[i].connect_to: for connection in mission_order[i].connect_to:
required_mission = missions[connection]
if connection == -1: if connection == -1:
connect(multiworld, player, names, "Menu", missions[i]) connect(multiworld, player, names, "Menu", mission)
elif required_mission is None:
continue
else: else:
connect(multiworld, player, names, missions[connection], missions[i], connect(multiworld, player, names, required_mission, mission,
(lambda name, missions_req: (lambda state: state.has(f"Beat {name}", player) and (lambda name, missions_req: (lambda state: state.has(f"Beat {name}", player) and
state._sc2wol_cleared_missions(multiworld, player, state._sc2wol_cleared_missions(multiworld, player,
missions_req))) missions_req)))
(missions[connection], mission_order[i].number)) (missions[connection], mission_order[i].number))
connections.append(connection + 1) connections.append(slot_map[connection])
mission_req_table.update({missions[i]: MissionInfo( mission_req_table.update({mission: MissionInfo(
vanilla_mission_req_table[missions[i]].id, connections, mission_order[i].category, vanilla_mission_req_table[mission].id, connections, mission_order[i].category,
number=mission_order[i].number, number=mission_order[i].number,
completion_critical=mission_order[i].completion_critical, completion_critical=mission_order[i].completion_critical,
or_requirements=mission_order[i].or_requirements)}) or_requirements=mission_order[i].or_requirements)})
final_mission_id = vanilla_mission_req_table[final_mission].id final_mission_id = vanilla_mission_req_table[final_mission].id
return mission_req_table, final_mission_id, final_mission + ': Victory'
# 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'
def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): return mission_req_table, final_mission_id, final_location
existingRegions = set()
for region in regions:
existingRegions.add(region.name)
if (regionNames - existingRegions):
raise Exception("Starcraft: the following regions are used in locations: {}, but no such region exists".format(
regionNames - existingRegions))
def create_location(player: int, location_data: LocationData, region: Region, def create_location(player: int, location_data: LocationData, region: Region,
location_cache: List[Location]) -> Location: location_cache: List[Location]) -> Location:

View File

@ -7,10 +7,10 @@ from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups,
get_basic_units get_basic_units
from .Locations import get_locations from .Locations import get_locations
from .Regions import create_regions from .Regions import create_regions
from .Options import sc2wol_options, get_option_value, get_option_set_value from .Options import sc2wol_options, get_option_value
from .LogicMixin import SC2WoLLogic from .LogicMixin import SC2WoLLogic
from .PoolFilter import filter_missions, filter_items, get_item_upgrades from .PoolFilter import filter_missions, filter_items, get_item_upgrades
from .MissionTables import get_starting_mission_locations, MissionInfo from .MissionTables import starting_mission_locations, MissionInfo
class Starcraft2WoLWebWorld(WebWorld): class Starcraft2WoLWebWorld(WebWorld):
@ -137,7 +137,6 @@ def assign_starter_items(multiworld: MultiWorld, player: int, excluded_items: Se
# The first world should also be the starting world # The first world should also be the starting world
first_mission = list(multiworld.worlds[player].mission_req_table)[0] first_mission = list(multiworld.worlds[player].mission_req_table)[0]
starting_mission_locations = get_starting_mission_locations(multiworld, player)
if first_mission in starting_mission_locations: if first_mission in starting_mission_locations:
first_location = starting_mission_locations[first_mission] first_location = starting_mission_locations[first_mission]
elif first_mission == "In Utter Darkness": elif first_mission == "In Utter Darkness":
@ -174,7 +173,7 @@ def get_item_pool(multiworld: MultiWorld, player: int, mission_req_table: Dict[s
locked_items = [] locked_items = []
# YAML items # YAML items
yaml_locked_items = get_option_set_value(multiworld, player, 'locked_items') yaml_locked_items = get_option_value(multiworld, player, 'locked_items')
for name, data in item_table.items(): for name, data in item_table.items():
if name not in excluded_items: if name not in excluded_items: