1631 lines
75 KiB
Python
1631 lines
75 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import ctypes
|
|
import enum
|
|
import inspect
|
|
import logging
|
|
import multiprocessing
|
|
import os.path
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import typing
|
|
import queue
|
|
import zipfile
|
|
import io
|
|
import random
|
|
import concurrent.futures
|
|
from pathlib import Path
|
|
|
|
# CommonClient import first to trigger ModuleUpdater
|
|
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
|
from Utils import init_logging, is_windows, async_start
|
|
from . import ItemNames, Options
|
|
from .ItemGroups import item_name_groups
|
|
from .Options import (
|
|
MissionOrder, KerriganPrimalStatus, kerrigan_unit_available, KerriganPresence,
|
|
GameSpeed, GenericUpgradeItems, GenericUpgradeResearch, ColorChoice, GenericUpgradeMissions,
|
|
LocationInclusion, ExtraLocations, MasteryLocations, ChallengeLocations, VanillaLocations,
|
|
DisableForcedCamera, SkipCutscenes, GrantStoryTech, GrantStoryLevels, TakeOverAIAllies, RequiredTactics,
|
|
SpearOfAdunPresence, SpearOfAdunPresentInNoBuild, SpearOfAdunAutonomouslyCastAbilityPresence,
|
|
SpearOfAdunAutonomouslyCastPresentInNoBuild
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
init_logging("SC2Client", exception_logger="Client")
|
|
|
|
logger = logging.getLogger("Client")
|
|
sc2_logger = logging.getLogger("Starcraft2")
|
|
|
|
import nest_asyncio
|
|
from worlds._sc2common import bot
|
|
from worlds._sc2common.bot.data import Race
|
|
from worlds._sc2common.bot.main import run_game
|
|
from worlds._sc2common.bot.player import Bot
|
|
from .Items import (lookup_id_to_name, get_full_item_list, ItemData, type_flaggroups, upgrade_numbers,
|
|
upgrade_numbers_all)
|
|
from .Locations import SC2WOL_LOC_ID_OFFSET, LocationType, SC2HOTS_LOC_ID_OFFSET
|
|
from .MissionTables import (lookup_id_to_mission, SC2Campaign, lookup_name_to_mission,
|
|
lookup_id_to_campaign, MissionConnection, SC2Mission, campaign_mission_table, SC2Race)
|
|
from .Regions import MissionInfo
|
|
|
|
import colorama
|
|
from Options import Option
|
|
from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart, add_json_item, add_json_location, add_json_text, JSONTypes
|
|
from MultiServer import mark_raw
|
|
|
|
pool = concurrent.futures.ThreadPoolExecutor(1)
|
|
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
nest_asyncio.apply(loop)
|
|
MAX_BONUS: int = 28
|
|
VICTORY_MODULO: int = 100
|
|
|
|
# GitHub repo where the Map/mod data is hosted for /download_data command
|
|
DATA_REPO_OWNER = "Ziktofel"
|
|
DATA_REPO_NAME = "Archipelago-SC2-data"
|
|
DATA_API_VERSION = "API3"
|
|
|
|
# Bot controller
|
|
CONTROLLER_HEALTH: int = 38281
|
|
CONTROLLER2_HEALTH: int = 38282
|
|
|
|
# Games
|
|
STARCRAFT2 = "Starcraft 2"
|
|
STARCRAFT2_WOL = "Starcraft 2 Wings of Liberty"
|
|
|
|
|
|
# Data version file path.
|
|
# This file is used to tell if the downloaded data are outdated
|
|
# Associated with /download_data command
|
|
def get_metadata_file() -> str:
|
|
return os.environ["SC2PATH"] + os.sep + "ArchipelagoSC2Metadata.txt"
|
|
|
|
|
|
class ConfigurableOptionType(enum.Enum):
|
|
INTEGER = enum.auto()
|
|
ENUM = enum.auto()
|
|
|
|
class ConfigurableOptionInfo(typing.NamedTuple):
|
|
name: str
|
|
variable_name: str
|
|
option_class: typing.Type[Option]
|
|
option_type: ConfigurableOptionType = ConfigurableOptionType.ENUM
|
|
can_break_logic: bool = False
|
|
|
|
|
|
class ColouredMessage:
|
|
def __init__(self, text: str = '') -> None:
|
|
self.parts: typing.List[dict] = []
|
|
if text:
|
|
self(text)
|
|
def __call__(self, text: str) -> 'ColouredMessage':
|
|
add_json_text(self.parts, text)
|
|
return self
|
|
def coloured(self, text: str, colour: str) -> 'ColouredMessage':
|
|
add_json_text(self.parts, text, type="color", color=colour)
|
|
return self
|
|
def location(self, location_id: int, player_id: int) -> 'ColouredMessage':
|
|
add_json_location(self.parts, location_id, player_id)
|
|
return self
|
|
def item(self, item_id: int, player_id: int, flags: int = 0) -> 'ColouredMessage':
|
|
add_json_item(self.parts, item_id, player_id, flags)
|
|
return self
|
|
def player(self, player_id: int) -> 'ColouredMessage':
|
|
add_json_text(self.parts, str(player_id), type=JSONTypes.player_id)
|
|
return self
|
|
def send(self, ctx: SC2Context) -> None:
|
|
ctx.on_print_json({"data": self.parts, "cmd": "PrintJSON"})
|
|
|
|
|
|
class StarcraftClientProcessor(ClientCommandProcessor):
|
|
ctx: SC2Context
|
|
|
|
def formatted_print(self, text: str) -> None:
|
|
"""Prints with kivy formatting to the GUI, and also prints to command-line and to all logs"""
|
|
# Note(mm): Bold/underline can help readability, but unfortunately the CommonClient does not filter bold tags from command-line output.
|
|
# Regardless, using `on_print_json` to get formatted text in the GUI and output in the command-line and in the logs,
|
|
# without having to branch code from CommonClient
|
|
self.ctx.on_print_json({"data": [{"text": text}]})
|
|
|
|
def _cmd_difficulty(self, difficulty: str = "") -> bool:
|
|
"""Overrides the current difficulty set for the world. Takes the argument casual, normal, hard, or brutal"""
|
|
options = difficulty.split()
|
|
num_options = len(options)
|
|
|
|
if num_options > 0:
|
|
difficulty_choice = options[0].lower()
|
|
if difficulty_choice == "casual":
|
|
self.ctx.difficulty_override = 0
|
|
elif difficulty_choice == "normal":
|
|
self.ctx.difficulty_override = 1
|
|
elif difficulty_choice == "hard":
|
|
self.ctx.difficulty_override = 2
|
|
elif difficulty_choice == "brutal":
|
|
self.ctx.difficulty_override = 3
|
|
else:
|
|
self.output("Unable to parse difficulty '" + options[0] + "'")
|
|
return False
|
|
|
|
self.output("Difficulty set to " + options[0])
|
|
return True
|
|
|
|
else:
|
|
if self.ctx.difficulty == -1:
|
|
self.output("Please connect to a seed before checking difficulty.")
|
|
else:
|
|
current_difficulty = self.ctx.difficulty
|
|
if self.ctx.difficulty_override >= 0:
|
|
current_difficulty = self.ctx.difficulty_override
|
|
self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][current_difficulty])
|
|
self.output("To change the difficulty, add the name of the difficulty after the command.")
|
|
return False
|
|
|
|
|
|
def _cmd_game_speed(self, game_speed: str = "") -> bool:
|
|
"""Overrides the current game speed for the world.
|
|
Takes the arguments default, slower, slow, normal, fast, faster"""
|
|
options = game_speed.split()
|
|
num_options = len(options)
|
|
|
|
if num_options > 0:
|
|
speed_choice = options[0].lower()
|
|
if speed_choice == "default":
|
|
self.ctx.game_speed_override = 0
|
|
elif speed_choice == "slower":
|
|
self.ctx.game_speed_override = 1
|
|
elif speed_choice == "slow":
|
|
self.ctx.game_speed_override = 2
|
|
elif speed_choice == "normal":
|
|
self.ctx.game_speed_override = 3
|
|
elif speed_choice == "fast":
|
|
self.ctx.game_speed_override = 4
|
|
elif speed_choice == "faster":
|
|
self.ctx.game_speed_override = 5
|
|
else:
|
|
self.output("Unable to parse game speed '" + options[0] + "'")
|
|
return False
|
|
|
|
self.output("Game speed set to " + options[0])
|
|
return True
|
|
|
|
else:
|
|
if self.ctx.game_speed == -1:
|
|
self.output("Please connect to a seed before checking game speed.")
|
|
else:
|
|
current_speed = self.ctx.game_speed
|
|
if self.ctx.game_speed_override >= 0:
|
|
current_speed = self.ctx.game_speed_override
|
|
self.output("Current game speed: "
|
|
+ ["Default", "Slower", "Slow", "Normal", "Fast", "Faster"][current_speed])
|
|
self.output("To change the game speed, add the name of the speed after the command,"
|
|
" or Default to select based on difficulty.")
|
|
return False
|
|
|
|
@mark_raw
|
|
def _cmd_received(self, filter_search: str = "") -> bool:
|
|
"""List received items.
|
|
Pass in a parameter to filter the search by partial item name or exact item group."""
|
|
# Groups must be matched case-sensitively, so we properly capitalize the search term
|
|
# eg. "Spear of Adun" over "Spear Of Adun" or "spear of adun"
|
|
# This fails a lot of item name matches, but those should be found by partial name match
|
|
formatted_filter_search = " ".join([(part.lower() if len(part) <= 3 else part.lower().capitalize()) for part in filter_search.split()])
|
|
|
|
def item_matches_filter(item_name: str) -> bool:
|
|
# The filter can be an exact group name or a partial item name
|
|
# Partial item name can be matched case-insensitively
|
|
if filter_search.lower() in item_name.lower():
|
|
return True
|
|
# The search term should already be formatted as a group name
|
|
if formatted_filter_search in item_name_groups and item_name in item_name_groups[formatted_filter_search]:
|
|
return True
|
|
return False
|
|
|
|
items = get_full_item_list()
|
|
categorized_items: typing.Dict[SC2Race, typing.List[int]] = {}
|
|
parent_to_child: typing.Dict[int, typing.List[int]] = {}
|
|
items_received: typing.Dict[int, typing.List[NetworkItem]] = {}
|
|
filter_match_count = 0
|
|
for item in self.ctx.items_received:
|
|
items_received.setdefault(item.item, []).append(item)
|
|
items_received_set = set(items_received)
|
|
for item_data in items.values():
|
|
if item_data.parent_item:
|
|
parent_to_child.setdefault(items[item_data.parent_item].code, []).append(item_data.code)
|
|
else:
|
|
categorized_items.setdefault(item_data.race, []).append(item_data.code)
|
|
for faction in SC2Race:
|
|
has_printed_faction_title = False
|
|
def print_faction_title():
|
|
if not has_printed_faction_title:
|
|
self.formatted_print(f" [u]{faction.name}[/u] ")
|
|
|
|
for item_id in categorized_items[faction]:
|
|
item_name = self.ctx.item_names.lookup_in_game(item_id)
|
|
received_child_items = items_received_set.intersection(parent_to_child.get(item_id, []))
|
|
matching_children = [child for child in received_child_items
|
|
if item_matches_filter(self.ctx.item_names.lookup_in_game(child))]
|
|
received_items_of_this_type = items_received.get(item_id, [])
|
|
item_is_match = item_matches_filter(item_name)
|
|
if item_is_match or len(matching_children) > 0:
|
|
# Print found item if it or its children match the filter
|
|
if item_is_match:
|
|
filter_match_count += len(received_items_of_this_type)
|
|
for item in received_items_of_this_type:
|
|
print_faction_title()
|
|
has_printed_faction_title = True
|
|
(ColouredMessage('* ').item(item.item, self.ctx.slot, flags=item.flags)
|
|
(" from ").location(item.location, self.ctx.slot)
|
|
(" by ").player(item.player)
|
|
).send(self.ctx)
|
|
|
|
if received_child_items:
|
|
# We have this item's children
|
|
if len(matching_children) == 0:
|
|
# ...but none of them match the filter
|
|
continue
|
|
|
|
if not received_items_of_this_type:
|
|
# We didn't receive the item itself
|
|
print_faction_title()
|
|
has_printed_faction_title = True
|
|
ColouredMessage("- ").coloured(item_name, "black")(" - not obtained").send(self.ctx)
|
|
|
|
for child_item in matching_children:
|
|
received_items_of_this_type = items_received.get(child_item, [])
|
|
for item in received_items_of_this_type:
|
|
filter_match_count += len(received_items_of_this_type)
|
|
(ColouredMessage(' * ').item(item.item, self.ctx.slot, flags=item.flags)
|
|
(" from ").location(item.location, self.ctx.slot)
|
|
(" by ").player(item.player)
|
|
).send(self.ctx)
|
|
|
|
non_matching_children = len(received_child_items) - len(matching_children)
|
|
if non_matching_children > 0:
|
|
self.formatted_print(f" + {non_matching_children} child items that don't match the filter")
|
|
if filter_search == "":
|
|
self.formatted_print(f"[b]Obtained: {len(self.ctx.items_received)} items[/b]")
|
|
else:
|
|
self.formatted_print(f"[b]Filter \"{filter_search}\" found {filter_match_count} out of {len(self.ctx.items_received)} obtained items[/b]")
|
|
return True
|
|
|
|
def _cmd_option(self, option_name: str = "", option_value: str = "") -> None:
|
|
"""Sets a Starcraft game option that can be changed after generation. Use "/option list" to see all options."""
|
|
|
|
LOGIC_WARNING = f" *Note changing this may result in logically unbeatable games*\n"
|
|
|
|
options = (
|
|
ConfigurableOptionInfo('kerrigan_presence', 'kerrigan_presence', Options.KerriganPresence, can_break_logic=True),
|
|
ConfigurableOptionInfo('soa_presence', 'spear_of_adun_presence', Options.SpearOfAdunPresence, can_break_logic=True),
|
|
ConfigurableOptionInfo('soa_in_nobuilds', 'spear_of_adun_present_in_no_build', Options.SpearOfAdunPresentInNoBuild, can_break_logic=True),
|
|
ConfigurableOptionInfo('control_ally', 'take_over_ai_allies', Options.TakeOverAIAllies, can_break_logic=True),
|
|
ConfigurableOptionInfo('minerals_per_item', 'minerals_per_item', Options.MineralsPerItem, ConfigurableOptionType.INTEGER),
|
|
ConfigurableOptionInfo('gas_per_item', 'vespene_per_item', Options.VespenePerItem, ConfigurableOptionType.INTEGER),
|
|
ConfigurableOptionInfo('supply_per_item', 'starting_supply_per_item', Options.StartingSupplyPerItem, ConfigurableOptionType.INTEGER),
|
|
ConfigurableOptionInfo('no_forced_camera', 'disable_forced_camera', Options.DisableForcedCamera),
|
|
ConfigurableOptionInfo('skip_cutscenes', 'skip_cutscenes', Options.SkipCutscenes),
|
|
)
|
|
|
|
WARNING_COLOUR = "salmon"
|
|
CMD_COLOUR = "slateblue"
|
|
boolean_option_map = {
|
|
'y': 'true', 'yes': 'true', 'n': 'false', 'no': 'false',
|
|
}
|
|
|
|
help_message = ColouredMessage(inspect.cleandoc("""
|
|
Options
|
|
--------------------
|
|
"""))('\n')
|
|
for option in options:
|
|
option_help_text = inspect.cleandoc(option.option_class.__doc__ or "No description provided.").split('\n', 1)[0]
|
|
help_message.coloured(option.name, CMD_COLOUR)(": " + " | ".join(option.option_class.options)
|
|
+ f" -- {option_help_text}\n")
|
|
if option.can_break_logic:
|
|
help_message.coloured(LOGIC_WARNING, WARNING_COLOUR)
|
|
help_message("--------------------\nEnter an option without arguments to see its current value.\n")
|
|
|
|
if not option_name or option_name == 'list' or option_name == 'help':
|
|
help_message.send(self.ctx)
|
|
return
|
|
for option in options:
|
|
if option_name == option.name:
|
|
option_value = boolean_option_map.get(option_value, option_value)
|
|
if not option_value:
|
|
pass
|
|
elif option.option_type == ConfigurableOptionType.ENUM and option_value in option.option_class.options:
|
|
self.ctx.__dict__[option.variable_name] = option.option_class.options[option_value]
|
|
elif option.option_type == ConfigurableOptionType.INTEGER:
|
|
try:
|
|
self.ctx.__dict__[option.variable_name] = int(option_value, base=0)
|
|
except:
|
|
self.output(f"{option_value} is not a valid integer")
|
|
else:
|
|
self.output(f"Unknown option value '{option_value}'")
|
|
ColouredMessage(f"{option.name} is '{option.option_class.get_option_name(self.ctx.__dict__[option.variable_name])}'").send(self.ctx)
|
|
break
|
|
else:
|
|
self.output(f"Unknown option '{option_name}'")
|
|
help_message.send(self.ctx)
|
|
|
|
def _cmd_color(self, faction: str = "", color: str = "") -> None:
|
|
"""Changes the player color for a given faction."""
|
|
player_colors = [
|
|
"White", "Red", "Blue", "Teal",
|
|
"Purple", "Yellow", "Orange", "Green",
|
|
"LightPink", "Violet", "LightGrey", "DarkGreen",
|
|
"Brown", "LightGreen", "DarkGrey", "Pink",
|
|
"Rainbow", "Random", "Default"
|
|
]
|
|
var_names = {
|
|
'raynor': 'player_color_raynor',
|
|
'kerrigan': 'player_color_zerg',
|
|
'primal': 'player_color_zerg_primal',
|
|
'protoss': 'player_color_protoss',
|
|
'nova': 'player_color_nova',
|
|
}
|
|
faction = faction.lower()
|
|
if not faction:
|
|
for faction_name, key in var_names.items():
|
|
self.output(f"Current player color for {faction_name}: {player_colors[self.ctx.__dict__[key]]}")
|
|
self.output("To change your color, add the faction name and color after the command.")
|
|
self.output("Available factions: " + ', '.join(var_names))
|
|
self.output("Available colors: " + ', '.join(player_colors))
|
|
return
|
|
elif faction not in var_names:
|
|
self.output(f"Unknown faction '{faction}'.")
|
|
self.output("Available factions: " + ', '.join(var_names))
|
|
return
|
|
match_colors = [player_color.lower() for player_color in player_colors]
|
|
if not color:
|
|
self.output(f"Current player color for {faction}: {player_colors[self.ctx.__dict__[var_names[faction]]]}")
|
|
self.output("To change this faction's colors, add the name of the color after the command.")
|
|
self.output("Available colors: " + ', '.join(player_colors))
|
|
else:
|
|
if color.lower() not in match_colors:
|
|
self.output(color + " is not a valid color. Available colors: " + ', '.join(player_colors))
|
|
return
|
|
if color.lower() == "random":
|
|
color = random.choice(player_colors[:16])
|
|
self.ctx.__dict__[var_names[faction]] = match_colors.index(color.lower())
|
|
self.ctx.pending_color_update = True
|
|
self.output(f"Color for {faction} set to " + player_colors[self.ctx.__dict__[var_names[faction]]])
|
|
|
|
def _cmd_disable_mission_check(self) -> bool:
|
|
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
|
|
the next mission in a chain the other player is doing."""
|
|
self.ctx.missions_unlocked = True
|
|
sc2_logger.info("Mission check has been disabled")
|
|
return True
|
|
|
|
def _cmd_play(self, mission_id: str = "") -> bool:
|
|
"""Start a Starcraft 2 mission"""
|
|
|
|
options = mission_id.split()
|
|
num_options = len(options)
|
|
|
|
if num_options > 0:
|
|
mission_number = int(options[0])
|
|
|
|
self.ctx.play_mission(mission_number)
|
|
|
|
else:
|
|
sc2_logger.info(
|
|
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
|
|
return False
|
|
|
|
return True
|
|
|
|
def _cmd_available(self) -> bool:
|
|
"""Get what missions are currently available to play"""
|
|
|
|
request_available_missions(self.ctx)
|
|
return True
|
|
|
|
def _cmd_unfinished(self) -> bool:
|
|
"""Get what missions are currently available to play and have not had all locations checked"""
|
|
|
|
request_unfinished_missions(self.ctx)
|
|
return True
|
|
|
|
@mark_raw
|
|
def _cmd_set_path(self, path: str = '') -> bool:
|
|
"""Manually set the SC2 install directory (if the automatic detection fails)."""
|
|
if path:
|
|
os.environ["SC2PATH"] = path
|
|
is_mod_installed_correctly()
|
|
return True
|
|
else:
|
|
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
|
return False
|
|
|
|
def _cmd_download_data(self) -> bool:
|
|
"""Download the most recent release of the necessary files for playing SC2 with
|
|
Archipelago. Will overwrite existing files."""
|
|
pool.submit(self._download_data)
|
|
return True
|
|
|
|
@staticmethod
|
|
def _download_data() -> bool:
|
|
if "SC2PATH" not in os.environ:
|
|
check_game_install_path()
|
|
|
|
if os.path.exists(get_metadata_file()):
|
|
with open(get_metadata_file(), "r") as f:
|
|
metadata = f.read()
|
|
else:
|
|
metadata = None
|
|
|
|
tempzip, metadata = download_latest_release_zip(
|
|
DATA_REPO_OWNER, DATA_REPO_NAME, DATA_API_VERSION, metadata=metadata, force_download=True)
|
|
|
|
if tempzip:
|
|
try:
|
|
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
|
|
sc2_logger.info(f"Download complete. Package installed.")
|
|
if metadata is not None:
|
|
with open(get_metadata_file(), "w") as f:
|
|
f.write(metadata)
|
|
finally:
|
|
os.remove(tempzip)
|
|
else:
|
|
sc2_logger.warning("Download aborted/failed. Read the log for more information.")
|
|
return False
|
|
return True
|
|
|
|
|
|
class SC2JSONtoTextParser(JSONtoTextParser):
|
|
def __init__(self, ctx) -> None:
|
|
self.handlers = {
|
|
"ItemSend": self._handle_color,
|
|
"ItemCheat": self._handle_color,
|
|
"Hint": self._handle_color,
|
|
}
|
|
super().__init__(ctx)
|
|
|
|
def _handle_color(self, node: JSONMessagePart) -> str:
|
|
codes = node["color"].split(";")
|
|
buffer = "".join(self.color_code(code) for code in codes if code in self.color_codes)
|
|
return buffer + self._handle_text(node) + '</c>'
|
|
|
|
def color_code(self, code: str) -> str:
|
|
return '<c val="' + self.color_codes[code] + '">'
|
|
|
|
|
|
class SC2Context(CommonContext):
|
|
command_processor = StarcraftClientProcessor
|
|
game = STARCRAFT2
|
|
items_handling = 0b111
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
super(SC2Context, self).__init__(*args, **kwargs)
|
|
self.raw_text_parser = SC2JSONtoTextParser(self)
|
|
|
|
self.difficulty = -1
|
|
self.game_speed = -1
|
|
self.disable_forced_camera = 0
|
|
self.skip_cutscenes = 0
|
|
self.all_in_choice = 0
|
|
self.mission_order = 0
|
|
self.player_color_raynor = ColorChoice.option_blue
|
|
self.player_color_zerg = ColorChoice.option_orange
|
|
self.player_color_zerg_primal = ColorChoice.option_purple
|
|
self.player_color_protoss = ColorChoice.option_blue
|
|
self.player_color_nova = ColorChoice.option_dark_grey
|
|
self.pending_color_update = False
|
|
self.kerrigan_presence = 0
|
|
self.kerrigan_primal_status = 0
|
|
self.levels_per_check = 0
|
|
self.checks_per_level = 1
|
|
self.mission_req_table: typing.Dict[SC2Campaign, typing.Dict[str, MissionInfo]] = {}
|
|
self.final_mission: int = 29
|
|
self.announcements: queue.Queue = queue.Queue()
|
|
self.sc2_run_task: typing.Optional[asyncio.Task] = None
|
|
self.missions_unlocked: bool = False # allow launching missions ignoring requirements
|
|
self.generic_upgrade_missions = 0
|
|
self.generic_upgrade_research = 0
|
|
self.generic_upgrade_items = 0
|
|
self.location_inclusions: typing.Dict[LocationType, int] = {}
|
|
self.plando_locations: typing.List[str] = []
|
|
self.current_tooltip = None
|
|
self.last_loc_list = None
|
|
self.difficulty_override = -1
|
|
self.game_speed_override = -1
|
|
self.mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {}
|
|
self.last_bot: typing.Optional[ArchipelagoBot] = None
|
|
self.slot_data_version = 2
|
|
self.grant_story_tech = 0
|
|
self.required_tactics = RequiredTactics.option_standard
|
|
self.take_over_ai_allies = TakeOverAIAllies.option_false
|
|
self.spear_of_adun_presence = SpearOfAdunPresence.option_not_present
|
|
self.spear_of_adun_present_in_no_build = SpearOfAdunPresentInNoBuild.option_false
|
|
self.spear_of_adun_autonomously_cast_ability_presence = SpearOfAdunAutonomouslyCastAbilityPresence.option_not_present
|
|
self.spear_of_adun_autonomously_cast_present_in_no_build = SpearOfAdunAutonomouslyCastPresentInNoBuild.option_false
|
|
self.minerals_per_item = 15
|
|
self.vespene_per_item = 15
|
|
self.starting_supply_per_item = 2
|
|
self.nova_covert_ops_only = False
|
|
self.kerrigan_levels_per_mission_completed = 0
|
|
|
|
async def server_auth(self, password_requested: bool = False) -> None:
|
|
self.game = STARCRAFT2
|
|
if password_requested and not self.password:
|
|
await super(SC2Context, self).server_auth(password_requested)
|
|
await self.get_username()
|
|
await self.send_connect()
|
|
if self.ui:
|
|
self.ui.first_check = True
|
|
|
|
def is_legacy_game(self):
|
|
return self.game == STARCRAFT2_WOL
|
|
|
|
def event_invalid_game(self):
|
|
if self.is_legacy_game():
|
|
self.game = STARCRAFT2
|
|
super().event_invalid_game()
|
|
else:
|
|
self.game = STARCRAFT2_WOL
|
|
async_start(self.send_connect())
|
|
|
|
def on_package(self, cmd: str, args: dict) -> None:
|
|
if cmd == "Connected":
|
|
self.difficulty = args["slot_data"]["game_difficulty"]
|
|
self.game_speed = args["slot_data"].get("game_speed", GameSpeed.option_default)
|
|
self.disable_forced_camera = args["slot_data"].get("disable_forced_camera", DisableForcedCamera.default)
|
|
self.skip_cutscenes = args["slot_data"].get("skip_cutscenes", SkipCutscenes.default)
|
|
self.all_in_choice = args["slot_data"]["all_in_map"]
|
|
self.slot_data_version = args["slot_data"].get("version", 2)
|
|
slot_req_table: dict = args["slot_data"]["mission_req"]
|
|
|
|
first_item = list(slot_req_table.keys())[0]
|
|
# Maintaining backwards compatibility with older slot data
|
|
if first_item in [str(campaign.id) for campaign in SC2Campaign]:
|
|
# Multi-campaign
|
|
self.mission_req_table = {}
|
|
for campaign_id in slot_req_table:
|
|
campaign = lookup_id_to_campaign[int(campaign_id)]
|
|
self.mission_req_table[campaign] = {
|
|
mission: self.parse_mission_info(mission_info)
|
|
for mission, mission_info in slot_req_table[campaign_id].items()
|
|
}
|
|
else:
|
|
# Old format
|
|
self.mission_req_table = {SC2Campaign.GLOBAL: {
|
|
mission: self.parse_mission_info(mission_info)
|
|
for mission, mission_info in slot_req_table.items()
|
|
}
|
|
}
|
|
|
|
self.mission_order = args["slot_data"].get("mission_order", MissionOrder.option_vanilla)
|
|
self.final_mission = args["slot_data"].get("final_mission", SC2Mission.ALL_IN.id)
|
|
self.player_color_raynor = args["slot_data"].get("player_color_terran_raynor", ColorChoice.option_blue)
|
|
self.player_color_zerg = args["slot_data"].get("player_color_zerg", ColorChoice.option_orange)
|
|
self.player_color_zerg_primal = args["slot_data"].get("player_color_zerg_primal", ColorChoice.option_purple)
|
|
self.player_color_protoss = args["slot_data"].get("player_color_protoss", ColorChoice.option_blue)
|
|
self.player_color_nova = args["slot_data"].get("player_color_nova", ColorChoice.option_dark_grey)
|
|
self.generic_upgrade_missions = args["slot_data"].get("generic_upgrade_missions", GenericUpgradeMissions.default)
|
|
self.generic_upgrade_items = args["slot_data"].get("generic_upgrade_items", GenericUpgradeItems.option_individual_items)
|
|
self.generic_upgrade_research = args["slot_data"].get("generic_upgrade_research", GenericUpgradeResearch.option_vanilla)
|
|
self.kerrigan_presence = args["slot_data"].get("kerrigan_presence", KerriganPresence.option_vanilla)
|
|
self.kerrigan_primal_status = args["slot_data"].get("kerrigan_primal_status", KerriganPrimalStatus.option_vanilla)
|
|
self.kerrigan_levels_per_mission_completed = args["slot_data"].get("kerrigan_levels_per_mission_completed", 0)
|
|
self.kerrigan_levels_per_mission_completed_cap = args["slot_data"].get("kerrigan_levels_per_mission_completed_cap", -1)
|
|
self.kerrigan_total_level_cap = args["slot_data"].get("kerrigan_total_level_cap", -1)
|
|
self.grant_story_tech = args["slot_data"].get("grant_story_tech", GrantStoryTech.option_false)
|
|
self.grant_story_levels = args["slot_data"].get("grant_story_levels", GrantStoryLevels.option_additive)
|
|
self.required_tactics = args["slot_data"].get("required_tactics", RequiredTactics.option_standard)
|
|
self.take_over_ai_allies = args["slot_data"].get("take_over_ai_allies", TakeOverAIAllies.option_false)
|
|
self.spear_of_adun_presence = args["slot_data"].get("spear_of_adun_presence", SpearOfAdunPresence.option_not_present)
|
|
self.spear_of_adun_present_in_no_build = args["slot_data"].get("spear_of_adun_present_in_no_build", SpearOfAdunPresentInNoBuild.option_false)
|
|
self.spear_of_adun_autonomously_cast_ability_presence = args["slot_data"].get("spear_of_adun_autonomously_cast_ability_presence", SpearOfAdunAutonomouslyCastAbilityPresence.option_not_present)
|
|
self.spear_of_adun_autonomously_cast_present_in_no_build = args["slot_data"].get("spear_of_adun_autonomously_cast_present_in_no_build", SpearOfAdunAutonomouslyCastPresentInNoBuild.option_false)
|
|
self.minerals_per_item = args["slot_data"].get("minerals_per_item", 15)
|
|
self.vespene_per_item = args["slot_data"].get("vespene_per_item", 15)
|
|
self.starting_supply_per_item = args["slot_data"].get("starting_supply_per_item", 2)
|
|
self.nova_covert_ops_only = args["slot_data"].get("nova_covert_ops_only", False)
|
|
|
|
if self.required_tactics == RequiredTactics.option_no_logic:
|
|
# Locking Grant Story Tech/Levels if no logic
|
|
self.grant_story_tech = GrantStoryTech.option_true
|
|
self.grant_story_levels = GrantStoryLevels.option_minimum
|
|
|
|
self.location_inclusions = {
|
|
LocationType.VICTORY: LocationInclusion.option_enabled, # Victory checks are always enabled
|
|
LocationType.VANILLA: args["slot_data"].get("vanilla_locations", VanillaLocations.default),
|
|
LocationType.EXTRA: args["slot_data"].get("extra_locations", ExtraLocations.default),
|
|
LocationType.CHALLENGE: args["slot_data"].get("challenge_locations", ChallengeLocations.default),
|
|
LocationType.MASTERY: args["slot_data"].get("mastery_locations", MasteryLocations.default),
|
|
}
|
|
self.plando_locations = args["slot_data"].get("plando_locations", [])
|
|
|
|
self.build_location_to_mission_mapping()
|
|
|
|
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
|
|
maps_present = is_mod_installed_correctly()
|
|
if os.path.exists(get_metadata_file()):
|
|
with open(get_metadata_file(), "r") as f:
|
|
current_ver = f.read()
|
|
sc2_logger.debug(f"Current version: {current_ver}")
|
|
if is_mod_update_available(DATA_REPO_OWNER, DATA_REPO_NAME, DATA_API_VERSION, current_ver):
|
|
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
|
|
elif maps_present:
|
|
sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). "
|
|
"Run /download_data to update them.")
|
|
|
|
@staticmethod
|
|
def parse_mission_info(mission_info: dict[str, typing.Any]) -> MissionInfo:
|
|
if mission_info.get("id") is not None:
|
|
mission_info["mission"] = lookup_id_to_mission[mission_info["id"]]
|
|
elif isinstance(mission_info["mission"], int):
|
|
mission_info["mission"] = lookup_id_to_mission[mission_info["mission"]]
|
|
|
|
return MissionInfo(
|
|
**{field: value for field, value in mission_info.items() if field in MissionInfo._fields}
|
|
)
|
|
|
|
def find_campaign(self, mission_name: str) -> SC2Campaign:
|
|
data = self.mission_req_table
|
|
for campaign in data.keys():
|
|
if mission_name in data[campaign].keys():
|
|
return campaign
|
|
sc2_logger.info(f"Attempted to find campaign of unknown mission '{mission_name}'; defaulting to GLOBAL")
|
|
return SC2Campaign.GLOBAL
|
|
|
|
|
|
|
|
def on_print_json(self, args: dict) -> None:
|
|
# goes to this world
|
|
if "receiving" in args and self.slot_concerns_self(args["receiving"]):
|
|
relevant = True
|
|
# found in this world
|
|
elif "item" in args and self.slot_concerns_self(args["item"].player):
|
|
relevant = True
|
|
# not related
|
|
else:
|
|
relevant = False
|
|
|
|
if relevant:
|
|
self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"])))
|
|
|
|
super(SC2Context, self).on_print_json(args)
|
|
|
|
def run_gui(self) -> None:
|
|
from .ClientGui import start_gui
|
|
start_gui(self)
|
|
|
|
|
|
async def shutdown(self) -> None:
|
|
await super(SC2Context, self).shutdown()
|
|
if self.last_bot:
|
|
self.last_bot.want_close = True
|
|
if self.sc2_run_task:
|
|
self.sc2_run_task.cancel()
|
|
|
|
def play_mission(self, mission_id: int) -> bool:
|
|
if self.missions_unlocked or is_mission_available(self, mission_id):
|
|
if self.sc2_run_task:
|
|
if not self.sc2_run_task.done():
|
|
sc2_logger.warning("Starcraft 2 Client is still running!")
|
|
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
|
|
if self.slot is None:
|
|
sc2_logger.warning("Launching Mission without Archipelago authentication, "
|
|
"checks will not be registered to server.")
|
|
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
|
|
name="Starcraft 2 Launch")
|
|
return True
|
|
else:
|
|
sc2_logger.info(
|
|
f"{lookup_id_to_mission[mission_id].mission_name} is not currently unlocked. "
|
|
f"Use /unfinished or /available to see what is available.")
|
|
return False
|
|
|
|
def build_location_to_mission_mapping(self) -> None:
|
|
mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = {
|
|
mission_info.mission.id: set() for campaign_mission in self.mission_req_table.values() for mission_info in campaign_mission.values()
|
|
}
|
|
|
|
for loc in self.server_locations:
|
|
offset = SC2WOL_LOC_ID_OFFSET if loc < SC2HOTS_LOC_ID_OFFSET \
|
|
else (SC2HOTS_LOC_ID_OFFSET - SC2Mission.ALL_IN.id * VICTORY_MODULO)
|
|
mission_id, objective = divmod(loc - offset, VICTORY_MODULO)
|
|
mission_id_to_location_ids[mission_id].add(objective)
|
|
self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in
|
|
mission_id_to_location_ids.items()}
|
|
|
|
def locations_for_mission(self, mission_name: str):
|
|
mission = lookup_name_to_mission[mission_name]
|
|
mission_id: int = mission.id
|
|
objectives = self.mission_id_to_location_ids[mission_id]
|
|
for objective in objectives:
|
|
yield get_location_offset(mission_id) + mission_id * VICTORY_MODULO + objective
|
|
|
|
|
|
class CompatItemHolder(typing.NamedTuple):
|
|
name: str
|
|
quantity: int = 1
|
|
|
|
|
|
async def main():
|
|
multiprocessing.freeze_support()
|
|
parser = get_base_parser()
|
|
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
|
args = parser.parse_args()
|
|
|
|
ctx = SC2Context(args.connect, args.password)
|
|
ctx.auth = args.name
|
|
if ctx.server_task is None:
|
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
|
|
|
if gui_enabled:
|
|
ctx.run_gui()
|
|
ctx.run_cli()
|
|
|
|
await ctx.exit_event.wait()
|
|
|
|
await ctx.shutdown()
|
|
|
|
# These items must be given to the player if the game is generated on version 2
|
|
API2_TO_API3_COMPAT_ITEMS: typing.Set[CompatItemHolder] = {
|
|
CompatItemHolder(ItemNames.PHOTON_CANNON),
|
|
CompatItemHolder(ItemNames.OBSERVER),
|
|
CompatItemHolder(ItemNames.WARP_HARMONIZATION),
|
|
CompatItemHolder(ItemNames.PROGRESSIVE_PROTOSS_GROUND_WEAPON, 3),
|
|
CompatItemHolder(ItemNames.PROGRESSIVE_PROTOSS_GROUND_ARMOR, 3),
|
|
CompatItemHolder(ItemNames.PROGRESSIVE_PROTOSS_SHIELDS, 3),
|
|
CompatItemHolder(ItemNames.PROGRESSIVE_PROTOSS_AIR_WEAPON, 3),
|
|
CompatItemHolder(ItemNames.PROGRESSIVE_PROTOSS_AIR_ARMOR, 3),
|
|
CompatItemHolder(ItemNames.PROGRESSIVE_PROTOSS_WEAPON_ARMOR_UPGRADE, 3)
|
|
}
|
|
|
|
|
|
def compat_item_to_network_items(compat_item: CompatItemHolder) -> typing.List[NetworkItem]:
|
|
item_id = get_full_item_list()[compat_item.name].code
|
|
network_item = NetworkItem(item_id, 0, 0, 0)
|
|
return compat_item.quantity * [network_item]
|
|
|
|
|
|
def calculate_items(ctx: SC2Context) -> typing.Dict[SC2Race, typing.List[int]]:
|
|
items = ctx.items_received.copy()
|
|
# Items unlocked in API2 by default (Prophecy default items)
|
|
if ctx.slot_data_version < 3:
|
|
for compat_item in API2_TO_API3_COMPAT_ITEMS:
|
|
items.extend(compat_item_to_network_items(compat_item))
|
|
|
|
network_item: NetworkItem
|
|
accumulators: typing.Dict[SC2Race, typing.List[int]] = {race: [0 for _ in type_flaggroups[race]] for race in SC2Race}
|
|
|
|
# Protoss Shield grouped item specific logic
|
|
shields_from_ground_upgrade: int = 0
|
|
shields_from_air_upgrade: int = 0
|
|
|
|
item_list = get_full_item_list()
|
|
for network_item in items:
|
|
name: str = lookup_id_to_name[network_item.item]
|
|
item_data: ItemData = item_list[name]
|
|
|
|
# exists exactly once
|
|
if item_data.quantity == 1:
|
|
accumulators[item_data.race][type_flaggroups[item_data.race][item_data.type]] |= 1 << item_data.number
|
|
|
|
# exists multiple times
|
|
elif item_data.type in ["Upgrade", "Progressive Upgrade","Progressive Upgrade 2"]:
|
|
flaggroup = type_flaggroups[item_data.race][item_data.type]
|
|
|
|
# Generic upgrades apply only to Weapon / Armor upgrades
|
|
if item_data.type != "Upgrade" or ctx.generic_upgrade_items == 0:
|
|
accumulators[item_data.race][flaggroup] += 1 << item_data.number
|
|
else:
|
|
if name == ItemNames.PROGRESSIVE_PROTOSS_GROUND_UPGRADE:
|
|
shields_from_ground_upgrade += 1
|
|
if name == ItemNames.PROGRESSIVE_PROTOSS_AIR_UPGRADE:
|
|
shields_from_air_upgrade += 1
|
|
for bundled_number in upgrade_numbers[item_data.number]:
|
|
accumulators[item_data.race][flaggroup] += 1 << bundled_number
|
|
|
|
# Regen bio-steel nerf with API3 - undo for older games
|
|
if ctx.slot_data_version < 3 and name == ItemNames.PROGRESSIVE_REGENERATIVE_BIO_STEEL:
|
|
current_level = (accumulators[item_data.race][flaggroup] >> item_data.number) % 4
|
|
if current_level == 2:
|
|
# Switch from level 2 to level 3 for compatibility
|
|
accumulators[item_data.race][flaggroup] += 1 << item_data.number
|
|
# sum
|
|
else:
|
|
if name == ItemNames.STARTING_MINERALS:
|
|
accumulators[item_data.race][type_flaggroups[item_data.race][item_data.type]] += ctx.minerals_per_item
|
|
elif name == ItemNames.STARTING_VESPENE:
|
|
accumulators[item_data.race][type_flaggroups[item_data.race][item_data.type]] += ctx.vespene_per_item
|
|
elif name == ItemNames.STARTING_SUPPLY:
|
|
accumulators[item_data.race][type_flaggroups[item_data.race][item_data.type]] += ctx.starting_supply_per_item
|
|
else:
|
|
accumulators[item_data.race][type_flaggroups[item_data.race][item_data.type]] += item_data.number
|
|
|
|
# Fix Shields from generic upgrades by unit class (Maximum of ground/air upgrades)
|
|
if shields_from_ground_upgrade > 0 or shields_from_air_upgrade > 0:
|
|
shield_upgrade_level = max(shields_from_ground_upgrade, shields_from_air_upgrade)
|
|
shield_upgrade_item = item_list[ItemNames.PROGRESSIVE_PROTOSS_SHIELDS]
|
|
for _ in range(0, shield_upgrade_level):
|
|
accumulators[shield_upgrade_item.race][type_flaggroups[shield_upgrade_item.race][shield_upgrade_item.type]] += 1 << shield_upgrade_item.number
|
|
|
|
# Kerrigan levels per check
|
|
accumulators[SC2Race.ZERG][type_flaggroups[SC2Race.ZERG]["Level"]] += (len(ctx.checked_locations) // ctx.checks_per_level) * ctx.levels_per_check
|
|
|
|
# Upgrades from completed missions
|
|
if ctx.generic_upgrade_missions > 0:
|
|
total_missions = sum(len(ctx.mission_req_table[campaign]) for campaign in ctx.mission_req_table)
|
|
for race in SC2Race:
|
|
if "Upgrade" not in type_flaggroups[race]:
|
|
continue
|
|
upgrade_flaggroup = type_flaggroups[race]["Upgrade"]
|
|
num_missions = ctx.generic_upgrade_missions * total_missions
|
|
amounts = [
|
|
num_missions // 100,
|
|
2 * num_missions // 100,
|
|
3 * num_missions // 100
|
|
]
|
|
upgrade_count = 0
|
|
completed = len([id for id in ctx.mission_id_to_location_ids if get_location_offset(id) + VICTORY_MODULO * id in ctx.checked_locations])
|
|
for amount in amounts:
|
|
if completed >= amount:
|
|
upgrade_count += 1
|
|
# Equivalent to "Progressive Weapon/Armor Upgrade" item
|
|
for bundled_number in upgrade_numbers[upgrade_numbers_all[race]]:
|
|
accumulators[race][upgrade_flaggroup] += upgrade_count << bundled_number
|
|
|
|
return accumulators
|
|
|
|
|
|
def calc_difficulty(difficulty: int):
|
|
if difficulty == 0:
|
|
return 'C'
|
|
elif difficulty == 1:
|
|
return 'N'
|
|
elif difficulty == 2:
|
|
return 'H'
|
|
elif difficulty == 3:
|
|
return 'B'
|
|
|
|
return 'X'
|
|
|
|
|
|
def get_kerrigan_level(ctx: SC2Context, items: typing.Dict[SC2Race, typing.List[int]], missions_beaten: int) -> int:
|
|
item_value = items[SC2Race.ZERG][type_flaggroups[SC2Race.ZERG]["Level"]]
|
|
mission_value = missions_beaten * ctx.kerrigan_levels_per_mission_completed
|
|
if ctx.kerrigan_levels_per_mission_completed_cap != -1:
|
|
mission_value = min(mission_value, ctx.kerrigan_levels_per_mission_completed_cap)
|
|
total_value = item_value + mission_value
|
|
if ctx.kerrigan_total_level_cap != -1:
|
|
total_value = min(total_value, ctx.kerrigan_total_level_cap)
|
|
return total_value
|
|
|
|
|
|
def calculate_kerrigan_options(ctx: SC2Context) -> int:
|
|
options = 0
|
|
|
|
# Bits 0, 1
|
|
# Kerrigan unit available
|
|
if ctx.kerrigan_presence in kerrigan_unit_available:
|
|
options |= 1 << 0
|
|
|
|
# Bit 2
|
|
# Kerrigan primal status by map
|
|
if ctx.kerrigan_primal_status == KerriganPrimalStatus.option_vanilla:
|
|
options |= 1 << 2
|
|
|
|
return options
|
|
|
|
|
|
def caclulate_soa_options(ctx: SC2Context) -> int:
|
|
options = 0
|
|
|
|
# Bits 0, 1
|
|
# SoA Calldowns available
|
|
soa_presence_value = 0
|
|
if ctx.spear_of_adun_presence == SpearOfAdunPresence.option_not_present:
|
|
soa_presence_value = 0
|
|
elif ctx.spear_of_adun_presence == SpearOfAdunPresence.option_lotv_protoss:
|
|
soa_presence_value = 1
|
|
elif ctx.spear_of_adun_presence == SpearOfAdunPresence.option_protoss:
|
|
soa_presence_value = 2
|
|
elif ctx.spear_of_adun_presence == SpearOfAdunPresence.option_everywhere:
|
|
soa_presence_value = 3
|
|
options |= soa_presence_value << 0
|
|
|
|
# Bit 2
|
|
# SoA Calldowns for no-builds
|
|
if ctx.spear_of_adun_present_in_no_build == SpearOfAdunPresentInNoBuild.option_true:
|
|
options |= 1 << 2
|
|
|
|
# Bits 3,4
|
|
# Autocasts
|
|
soa_autocasts_presence_value = 0
|
|
if ctx.spear_of_adun_autonomously_cast_ability_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_not_present:
|
|
soa_autocasts_presence_value = 0
|
|
elif ctx.spear_of_adun_autonomously_cast_ability_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_lotv_protoss:
|
|
soa_autocasts_presence_value = 1
|
|
elif ctx.spear_of_adun_autonomously_cast_ability_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_protoss:
|
|
soa_autocasts_presence_value = 2
|
|
elif ctx.spear_of_adun_autonomously_cast_ability_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_everywhere:
|
|
soa_autocasts_presence_value = 3
|
|
options |= soa_autocasts_presence_value << 3
|
|
|
|
# Bit 5
|
|
# Autocasts in no-builds
|
|
if ctx.spear_of_adun_autonomously_cast_present_in_no_build == SpearOfAdunAutonomouslyCastPresentInNoBuild.option_true:
|
|
options |= 1 << 5
|
|
|
|
return options
|
|
|
|
def kerrigan_primal(ctx: SC2Context, kerrigan_level: int) -> bool:
|
|
if ctx.kerrigan_primal_status == KerriganPrimalStatus.option_always_zerg:
|
|
return True
|
|
elif ctx.kerrigan_primal_status == KerriganPrimalStatus.option_always_human:
|
|
return False
|
|
elif ctx.kerrigan_primal_status == KerriganPrimalStatus.option_level_35:
|
|
return kerrigan_level >= 35
|
|
elif ctx.kerrigan_primal_status == KerriganPrimalStatus.option_half_completion:
|
|
total_missions = len(ctx.mission_id_to_location_ids)
|
|
completed = sum((mission_id * VICTORY_MODULO + get_location_offset(mission_id)) in ctx.checked_locations
|
|
for mission_id in ctx.mission_id_to_location_ids)
|
|
return completed >= (total_missions / 2)
|
|
elif ctx.kerrigan_primal_status == KerriganPrimalStatus.option_item:
|
|
codes = [item.item for item in ctx.items_received]
|
|
return get_full_item_list()[ItemNames.KERRIGAN_PRIMAL_FORM].code in codes
|
|
return False
|
|
|
|
async def starcraft_launch(ctx: SC2Context, mission_id: int):
|
|
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id].mission_name}. If game does not launch check log file for errors.")
|
|
|
|
with DllDirectory(None):
|
|
run_game(bot.maps.get(lookup_id_to_mission[mission_id].map_file), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
|
|
name="Archipelago", fullscreen=True)], realtime=True)
|
|
|
|
|
|
class ArchipelagoBot(bot.bot_ai.BotAI):
|
|
__slots__ = [
|
|
'game_running',
|
|
'mission_completed',
|
|
'boni',
|
|
'setup_done',
|
|
'ctx',
|
|
'mission_id',
|
|
'want_close',
|
|
'can_read_game',
|
|
'last_received_update',
|
|
]
|
|
|
|
def __init__(self, ctx: SC2Context, mission_id: int):
|
|
self.game_running = False
|
|
self.mission_completed = False
|
|
self.want_close = False
|
|
self.can_read_game = False
|
|
self.last_received_update: int = 0
|
|
self.setup_done = False
|
|
self.ctx = ctx
|
|
self.ctx.last_bot = self
|
|
self.mission_id = mission_id
|
|
self.boni = [False for _ in range(MAX_BONUS)]
|
|
|
|
super(ArchipelagoBot, self).__init__()
|
|
|
|
async def on_step(self, iteration: int):
|
|
if self.want_close:
|
|
self.want_close = False
|
|
await self._client.leave()
|
|
return
|
|
game_state = 0
|
|
if not self.setup_done:
|
|
self.setup_done = True
|
|
start_items = calculate_items(self.ctx)
|
|
missions_beaten = self.missions_beaten_count()
|
|
kerrigan_level = get_kerrigan_level(self.ctx, start_items, missions_beaten)
|
|
kerrigan_options = calculate_kerrigan_options(self.ctx)
|
|
soa_options = caclulate_soa_options(self.ctx)
|
|
if self.ctx.difficulty_override >= 0:
|
|
difficulty = calc_difficulty(self.ctx.difficulty_override)
|
|
else:
|
|
difficulty = calc_difficulty(self.ctx.difficulty)
|
|
if self.ctx.game_speed_override >= 0:
|
|
game_speed = self.ctx.game_speed_override
|
|
else:
|
|
game_speed = self.ctx.game_speed
|
|
await self.chat_send("?SetOptions {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
|
difficulty,
|
|
self.ctx.generic_upgrade_research,
|
|
self.ctx.all_in_choice,
|
|
game_speed,
|
|
self.ctx.disable_forced_camera,
|
|
self.ctx.skip_cutscenes,
|
|
kerrigan_options,
|
|
self.ctx.grant_story_tech,
|
|
self.ctx.take_over_ai_allies,
|
|
soa_options,
|
|
self.ctx.mission_order,
|
|
1 if self.ctx.nova_covert_ops_only else 0,
|
|
self.ctx.grant_story_levels
|
|
))
|
|
await self.chat_send("?GiveResources {} {} {}".format(
|
|
start_items[SC2Race.ANY][0],
|
|
start_items[SC2Race.ANY][1],
|
|
start_items[SC2Race.ANY][2]
|
|
))
|
|
await self.updateTerranTech(start_items)
|
|
await self.updateZergTech(start_items, kerrigan_level)
|
|
await self.updateProtossTech(start_items)
|
|
await self.updateColors()
|
|
await self.chat_send("?LoadFinished")
|
|
self.last_received_update = len(self.ctx.items_received)
|
|
|
|
else:
|
|
if self.ctx.pending_color_update:
|
|
await self.updateColors()
|
|
|
|
if not self.ctx.announcements.empty():
|
|
message = self.ctx.announcements.get(timeout=1)
|
|
await self.chat_send("?SendMessage " + message)
|
|
self.ctx.announcements.task_done()
|
|
|
|
# Archipelago reads the health
|
|
controller1_state = 0
|
|
controller2_state = 0
|
|
for unit in self.all_own_units():
|
|
if unit.health_max == CONTROLLER_HEALTH:
|
|
controller1_state = int(CONTROLLER_HEALTH - unit.health)
|
|
self.can_read_game = True
|
|
elif unit.health_max == CONTROLLER2_HEALTH:
|
|
controller2_state = int(CONTROLLER2_HEALTH - unit.health)
|
|
self.can_read_game = True
|
|
game_state = controller1_state + (controller2_state << 15)
|
|
|
|
if iteration == 160 and not game_state & 1:
|
|
await self.chat_send("?SendMessage Warning: Archipelago unable to connect or has lost connection to " +
|
|
"Starcraft 2 (This is likely a map issue)")
|
|
|
|
if self.last_received_update < len(self.ctx.items_received):
|
|
current_items = calculate_items(self.ctx)
|
|
missions_beaten = self.missions_beaten_count()
|
|
kerrigan_level = get_kerrigan_level(self.ctx, current_items, missions_beaten)
|
|
await self.updateTerranTech(current_items)
|
|
await self.updateZergTech(current_items, kerrigan_level)
|
|
await self.updateProtossTech(current_items)
|
|
self.last_received_update = len(self.ctx.items_received)
|
|
|
|
if game_state & 1:
|
|
if not self.game_running:
|
|
print("Archipelago Connected")
|
|
self.game_running = True
|
|
|
|
if self.can_read_game:
|
|
if game_state & (1 << 1) and not self.mission_completed:
|
|
if self.mission_id != self.ctx.final_mission:
|
|
print("Mission Completed")
|
|
await self.ctx.send_msgs(
|
|
[{"cmd": 'LocationChecks',
|
|
"locations": [get_location_offset(self.mission_id) + VICTORY_MODULO * self.mission_id]}])
|
|
self.mission_completed = True
|
|
else:
|
|
print("Game Complete")
|
|
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
|
|
self.mission_completed = True
|
|
|
|
for x, completed in enumerate(self.boni):
|
|
if not completed and game_state & (1 << (x + 2)):
|
|
await self.ctx.send_msgs(
|
|
[{"cmd": 'LocationChecks',
|
|
"locations": [get_location_offset(self.mission_id) + VICTORY_MODULO * self.mission_id + x + 1]}])
|
|
self.boni[x] = True
|
|
else:
|
|
await self.chat_send("?SendMessage LostConnection - Lost connection to game.")
|
|
|
|
def missions_beaten_count(self):
|
|
return len([location for location in self.ctx.checked_locations if location % VICTORY_MODULO == 0])
|
|
|
|
async def updateColors(self):
|
|
await self.chat_send("?SetColor rr " + str(self.ctx.player_color_raynor))
|
|
await self.chat_send("?SetColor ks " + str(self.ctx.player_color_zerg))
|
|
await self.chat_send("?SetColor pz " + str(self.ctx.player_color_zerg_primal))
|
|
await self.chat_send("?SetColor da " + str(self.ctx.player_color_protoss))
|
|
await self.chat_send("?SetColor nova " + str(self.ctx.player_color_nova))
|
|
self.ctx.pending_color_update = False
|
|
|
|
async def updateTerranTech(self, current_items):
|
|
terran_items = current_items[SC2Race.TERRAN]
|
|
await self.chat_send("?GiveTerranTech {} {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
|
terran_items[0], terran_items[1], terran_items[2], terran_items[3], terran_items[4],
|
|
terran_items[5], terran_items[6], terran_items[7], terran_items[8], terran_items[9], terran_items[10],
|
|
terran_items[11], terran_items[12], terran_items[13]))
|
|
|
|
async def updateZergTech(self, current_items, kerrigan_level):
|
|
zerg_items = current_items[SC2Race.ZERG]
|
|
kerrigan_primal_by_items = kerrigan_primal(self.ctx, kerrigan_level)
|
|
kerrigan_primal_bot_value = 1 if kerrigan_primal_by_items else 0
|
|
await self.chat_send("?GiveZergTech {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
|
kerrigan_level, kerrigan_primal_bot_value, zerg_items[0], zerg_items[1], zerg_items[2],
|
|
zerg_items[3], zerg_items[4], zerg_items[5], zerg_items[6], zerg_items[9], zerg_items[10], zerg_items[11]
|
|
))
|
|
|
|
async def updateProtossTech(self, current_items):
|
|
protoss_items = current_items[SC2Race.PROTOSS]
|
|
await self.chat_send("?GiveProtossTech {} {} {} {} {} {} {} {} {} {}".format(
|
|
protoss_items[0], protoss_items[1], protoss_items[2], protoss_items[3], protoss_items[4],
|
|
protoss_items[5], protoss_items[6], protoss_items[7], protoss_items[8], protoss_items[9]
|
|
))
|
|
|
|
|
|
def request_unfinished_missions(ctx: SC2Context) -> None:
|
|
if ctx.mission_req_table:
|
|
message = "Unfinished Missions: "
|
|
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
|
|
unfinished_locations: typing.Dict[SC2Mission, typing.List[str]] = {}
|
|
|
|
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
|
|
|
|
for mission in unfinished_missions:
|
|
objectives = set(ctx.locations_for_mission(mission))
|
|
if objectives:
|
|
remaining_objectives = objectives.difference(ctx.checked_locations)
|
|
unfinished_locations[mission] = [ctx.location_names.lookup_in_game(location_id) for location_id in remaining_objectives]
|
|
else:
|
|
unfinished_locations[mission] = []
|
|
|
|
# Removing All-In from location pool
|
|
final_mission = lookup_id_to_mission[ctx.final_mission]
|
|
if final_mission in unfinished_missions.keys():
|
|
message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message
|
|
if unfinished_missions[final_mission] == -1:
|
|
unfinished_missions.pop(final_mission)
|
|
|
|
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[ctx.find_campaign(mission)][mission].mission.id}] " +
|
|
mark_up_objectives(
|
|
f"[{len(unfinished_missions[mission])}/"
|
|
f"{sum(1 for _ in ctx.locations_for_mission(mission))}]",
|
|
ctx, unfinished_locations, mission)
|
|
for mission in unfinished_missions)
|
|
|
|
if ctx.ui:
|
|
ctx.ui.log_panels['All'].on_message_markup(message)
|
|
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
|
|
else:
|
|
sc2_logger.info(message)
|
|
else:
|
|
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
|
|
|
|
|
def calc_unfinished_missions(ctx: SC2Context, unlocks: typing.Optional[typing.Dict] = None):
|
|
unfinished_missions: typing.List[str] = []
|
|
locations_completed: typing.List[typing.Union[typing.Set[int], typing.Literal[-1]]] = []
|
|
|
|
if not unlocks:
|
|
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
|
|
|
|
available_missions = calc_available_missions(ctx, unlocks)
|
|
|
|
for name in available_missions:
|
|
objectives = set(ctx.locations_for_mission(name))
|
|
if objectives:
|
|
objectives_completed = ctx.checked_locations & objectives
|
|
if len(objectives_completed) < len(objectives):
|
|
unfinished_missions.append(name)
|
|
locations_completed.append(objectives_completed)
|
|
|
|
else: # infer that this is the final mission as it has no objectives
|
|
unfinished_missions.append(name)
|
|
locations_completed.append(-1)
|
|
|
|
return available_missions, dict(zip(unfinished_missions, locations_completed))
|
|
|
|
|
|
def is_mission_available(ctx: SC2Context, mission_id_to_check: int) -> bool:
|
|
unfinished_missions = calc_available_missions(ctx)
|
|
|
|
return any(mission_id_to_check == ctx.mission_req_table[ctx.find_campaign(mission)][mission].mission.id for mission in unfinished_missions)
|
|
|
|
|
|
def mark_up_mission_name(ctx: SC2Context, mission_name: str, unlock_table: typing.Dict) -> str:
|
|
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
|
|
|
|
campaign = ctx.find_campaign(mission_name)
|
|
mission_info = ctx.mission_req_table[campaign][mission_name]
|
|
if mission_info.completion_critical:
|
|
if ctx.ui:
|
|
message = "[color=AF99EF]" + mission_name + "[/color]"
|
|
else:
|
|
message = "*" + mission_name + "*"
|
|
else:
|
|
message = mission_name
|
|
|
|
if ctx.ui:
|
|
campaign_missions = list(ctx.mission_req_table[campaign].keys())
|
|
unlocks: typing.List[str]
|
|
index = campaign_missions.index(mission_name)
|
|
if index in unlock_table[campaign]:
|
|
unlocks = unlock_table[campaign][index]
|
|
else:
|
|
unlocks = []
|
|
|
|
if len(unlocks) > 0:
|
|
pre_message = f"[ref={mission_info.mission.id}|Unlocks: "
|
|
pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[ctx.find_campaign(unlock)][unlock].mission.id})" for unlock in unlocks)
|
|
pre_message += f"]"
|
|
message = pre_message + message + "[/ref]"
|
|
|
|
return message
|
|
|
|
|
|
def mark_up_objectives(message, ctx, unfinished_locations, mission):
|
|
formatted_message = message
|
|
|
|
if ctx.ui:
|
|
locations = unfinished_locations[mission]
|
|
campaign = ctx.find_campaign(mission)
|
|
|
|
pre_message = f"[ref={list(ctx.mission_req_table[campaign]).index(mission) + 30}|"
|
|
pre_message += "<br>".join(location for location in locations)
|
|
pre_message += f"]"
|
|
formatted_message = pre_message + message + "[/ref]"
|
|
|
|
return formatted_message
|
|
|
|
|
|
def request_available_missions(ctx: SC2Context):
|
|
if ctx.mission_req_table:
|
|
message = "Available Missions: "
|
|
|
|
# Initialize mission unlock table
|
|
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
|
|
|
|
missions = calc_available_missions(ctx, unlocks)
|
|
message += \
|
|
", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
|
|
f"[{ctx.mission_req_table[ctx.find_campaign(mission)][mission].mission.id}]"
|
|
for mission in missions)
|
|
|
|
if ctx.ui:
|
|
ctx.ui.log_panels['All'].on_message_markup(message)
|
|
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
|
|
else:
|
|
sc2_logger.info(message)
|
|
else:
|
|
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
|
|
|
|
|
def calc_available_missions(ctx: SC2Context, unlocks: typing.Optional[dict] = None) -> typing.List[str]:
|
|
available_missions: typing.List[str] = []
|
|
missions_complete = 0
|
|
|
|
# Get number of missions completed
|
|
for loc in ctx.checked_locations:
|
|
if loc % VICTORY_MODULO == 0:
|
|
missions_complete += 1
|
|
|
|
for campaign in ctx.mission_req_table:
|
|
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
|
|
for mission_name in ctx.mission_req_table[campaign]:
|
|
if unlocks:
|
|
for unlock in ctx.mission_req_table[campaign][mission_name].required_world:
|
|
parsed_unlock = parse_unlock(unlock)
|
|
# TODO prophecy-only wants to connect to WoL here
|
|
index = parsed_unlock.connect_to - 1
|
|
unlock_mission = list(ctx.mission_req_table[parsed_unlock.campaign])[index]
|
|
unlock_campaign = ctx.find_campaign(unlock_mission)
|
|
if unlock_campaign in unlocks:
|
|
if index not in unlocks[unlock_campaign]:
|
|
unlocks[unlock_campaign][index] = list()
|
|
unlocks[unlock_campaign][index].append(mission_name)
|
|
|
|
if mission_reqs_completed(ctx, mission_name, missions_complete):
|
|
available_missions.append(mission_name)
|
|
|
|
return available_missions
|
|
|
|
|
|
def parse_unlock(unlock: typing.Union[typing.Dict[typing.Literal["connect_to", "campaign"], int], MissionConnection, int]) -> MissionConnection:
|
|
if isinstance(unlock, int):
|
|
# Legacy
|
|
return MissionConnection(unlock)
|
|
elif isinstance(unlock, MissionConnection):
|
|
return unlock
|
|
else:
|
|
# Multi-campaign
|
|
return MissionConnection(unlock["connect_to"], lookup_id_to_campaign[unlock["campaign"]])
|
|
|
|
|
|
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int) -> bool:
|
|
"""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
|
|
"""
|
|
campaign = ctx.find_campaign(mission_name)
|
|
|
|
if len(ctx.mission_req_table[campaign][mission_name].required_world) >= 1:
|
|
# A check for when the requirements are being or'd
|
|
or_success = False
|
|
|
|
# Loop through required missions
|
|
for req_mission in ctx.mission_req_table[campaign][mission_name].required_world:
|
|
req_success = True
|
|
parsed_req_mission = parse_unlock(req_mission)
|
|
|
|
# Check if required mission has been completed
|
|
mission_id = ctx.mission_req_table[parsed_req_mission.campaign][
|
|
list(ctx.mission_req_table[parsed_req_mission.campaign])[parsed_req_mission.connect_to - 1]].mission.id
|
|
if not (mission_id * VICTORY_MODULO + get_location_offset(mission_id)) in ctx.checked_locations:
|
|
if not ctx.mission_req_table[campaign][mission_name].or_requirements:
|
|
return False
|
|
else:
|
|
req_success = False
|
|
|
|
# Grid-specific logic (to avoid long path checks and infinite recursion)
|
|
if ctx.mission_order in (MissionOrder.option_grid, MissionOrder.option_mini_grid, MissionOrder.option_medium_grid):
|
|
if req_success:
|
|
return True
|
|
else:
|
|
if parsed_req_mission == ctx.mission_req_table[campaign][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[parsed_req_mission.campaign])[parsed_req_mission.connect_to - 1], missions_complete):
|
|
if not ctx.mission_req_table[campaign][mission_name].or_requirements:
|
|
return False
|
|
else:
|
|
req_success = False
|
|
|
|
# If requirement check succeeded mark or as satisfied
|
|
if ctx.mission_req_table[campaign][mission_name].or_requirements and req_success:
|
|
or_success = True
|
|
|
|
if ctx.mission_req_table[campaign][mission_name].or_requirements:
|
|
# Return false if or requirements not met
|
|
if not or_success:
|
|
return False
|
|
|
|
# Check number of missions
|
|
if missions_complete >= ctx.mission_req_table[campaign][mission_name].number:
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def initialize_blank_mission_dict(location_table: typing.Dict[SC2Campaign, typing.Dict[str, MissionInfo]]):
|
|
unlocks: typing.Dict[SC2Campaign, typing.Dict] = {}
|
|
|
|
for mission in list(location_table):
|
|
unlocks[mission] = {}
|
|
|
|
return unlocks
|
|
|
|
|
|
def check_game_install_path() -> bool:
|
|
# First thing: go to the default location for ExecuteInfo.
|
|
# An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it.
|
|
if is_windows:
|
|
# The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow.
|
|
# https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555#
|
|
import ctypes.wintypes
|
|
CSIDL_PERSONAL = 5 # My Documents
|
|
SHGFP_TYPE_CURRENT = 0 # Get current, not default value
|
|
|
|
buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
|
|
ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf)
|
|
documentspath: str = buf.value
|
|
einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt"))
|
|
else:
|
|
einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF]))
|
|
|
|
# Check if the file exists.
|
|
if os.path.isfile(einfo):
|
|
|
|
# Open the file and read it, picking out the latest executable's path.
|
|
with open(einfo) as f:
|
|
content = f.read()
|
|
if content:
|
|
search_result = re.search(r" = (.*)Versions", content)
|
|
if not search_result:
|
|
sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, "
|
|
"then try again.")
|
|
return False
|
|
base = search_result.group(1)
|
|
|
|
if os.path.exists(base):
|
|
executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions")
|
|
|
|
# Finally, check the path for an actual executable.
|
|
# If we find one, great. Set up the SC2PATH.
|
|
if os.path.isfile(executable):
|
|
sc2_logger.info(f"Found an SC2 install at {base}!")
|
|
sc2_logger.debug(f"Latest executable at {executable}.")
|
|
os.environ["SC2PATH"] = base
|
|
sc2_logger.debug(f"SC2PATH set to {base}.")
|
|
return True
|
|
else:
|
|
sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.")
|
|
else:
|
|
sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.")
|
|
else:
|
|
sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. "
|
|
f"If that fails, please run /set_path with your SC2 install directory.")
|
|
return False
|
|
|
|
|
|
def is_mod_installed_correctly() -> bool:
|
|
"""Searches for all required files."""
|
|
if "SC2PATH" not in os.environ:
|
|
check_game_install_path()
|
|
sc2_path: str = os.environ["SC2PATH"]
|
|
mapdir = sc2_path / Path('Maps/ArchipelagoCampaign')
|
|
mods = ["ArchipelagoCore", "ArchipelagoPlayer", "ArchipelagoPlayerSuper", "ArchipelagoPatches",
|
|
"ArchipelagoTriggers", "ArchipelagoPlayerWoL", "ArchipelagoPlayerHotS",
|
|
"ArchipelagoPlayerLotV", "ArchipelagoPlayerLotVPrologue", "ArchipelagoPlayerNCO"]
|
|
modfiles = [sc2_path / Path("Mods/" + mod + ".SC2Mod") for mod in mods]
|
|
wol_required_maps: typing.List[str] = ["WoL" + os.sep + mission.map_file + ".SC2Map" for mission in SC2Mission
|
|
if mission.campaign in (SC2Campaign.WOL, SC2Campaign.PROPHECY)]
|
|
hots_required_maps: typing.List[str] = ["HotS" + os.sep + mission.map_file + ".SC2Map" for mission in campaign_mission_table[SC2Campaign.HOTS]]
|
|
lotv_required_maps: typing.List[str] = ["LotV" + os.sep + mission.map_file + ".SC2Map" for mission in SC2Mission
|
|
if mission.campaign in (SC2Campaign.LOTV, SC2Campaign.PROLOGUE, SC2Campaign.EPILOGUE)]
|
|
nco_required_maps: typing.List[str] = ["NCO" + os.sep + mission.map_file + ".SC2Map" for mission in campaign_mission_table[SC2Campaign.NCO]]
|
|
required_maps = wol_required_maps + hots_required_maps + lotv_required_maps + nco_required_maps
|
|
needs_files = False
|
|
|
|
# Check for maps.
|
|
missing_maps: typing.List[str] = []
|
|
for mapfile in required_maps:
|
|
if not os.path.isfile(mapdir / mapfile):
|
|
missing_maps.append(mapfile)
|
|
if len(missing_maps) >= 19:
|
|
sc2_logger.warning(f"All map files missing from {mapdir}.")
|
|
needs_files = True
|
|
elif len(missing_maps) > 0:
|
|
for map in missing_maps:
|
|
sc2_logger.debug(f"Missing {map} from {mapdir}.")
|
|
sc2_logger.warning(f"Missing {len(missing_maps)} map files.")
|
|
needs_files = True
|
|
else: # Must be no maps missing
|
|
sc2_logger.info(f"All maps found in {mapdir}.")
|
|
|
|
# Check for mods.
|
|
for modfile in modfiles:
|
|
if os.path.isfile(modfile) or os.path.isdir(modfile):
|
|
sc2_logger.info(f"Archipelago mod found at {modfile}.")
|
|
else:
|
|
sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.")
|
|
needs_files = True
|
|
|
|
# Final verdict.
|
|
if needs_files:
|
|
sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.")
|
|
return False
|
|
else:
|
|
sc2_logger.debug(f"All map/mod files are properly installed.")
|
|
return True
|
|
|
|
|
|
class DllDirectory:
|
|
# Credit to Black Sliver for this code.
|
|
# More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw
|
|
_old: typing.Optional[str] = None
|
|
_new: typing.Optional[str] = None
|
|
|
|
def __init__(self, new: typing.Optional[str]):
|
|
self._new = new
|
|
|
|
def __enter__(self):
|
|
old = self.get()
|
|
if self.set(self._new):
|
|
self._old = old
|
|
|
|
def __exit__(self, *args):
|
|
if self._old is not None:
|
|
self.set(self._old)
|
|
|
|
@staticmethod
|
|
def get() -> typing.Optional[str]:
|
|
if sys.platform == "win32":
|
|
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
|
|
buf = ctypes.create_unicode_buffer(n)
|
|
ctypes.windll.kernel32.GetDllDirectoryW(n, buf)
|
|
return buf.value
|
|
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
|
return None
|
|
|
|
@staticmethod
|
|
def set(s: typing.Optional[str]) -> bool:
|
|
if sys.platform == "win32":
|
|
return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0
|
|
# NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific
|
|
return False
|
|
|
|
|
|
def download_latest_release_zip(
|
|
owner: str,
|
|
repo: str,
|
|
api_version: str,
|
|
metadata: typing.Optional[str] = None,
|
|
force_download=False
|
|
) -> typing.Tuple[str, typing.Optional[str]]:
|
|
"""Downloads the latest release of a GitHub repo to the current directory as a .zip file."""
|
|
import requests
|
|
|
|
headers = {"Accept": 'application/vnd.github.v3+json'}
|
|
url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{api_version}"
|
|
|
|
r1 = requests.get(url, headers=headers)
|
|
if r1.status_code == 200:
|
|
latest_metadata = r1.json()
|
|
cleanup_downloaded_metadata(latest_metadata)
|
|
latest_metadata = str(latest_metadata)
|
|
# sc2_logger.info(f"Latest version: {latest_metadata}.")
|
|
else:
|
|
sc2_logger.warning(f"Status code: {r1.status_code}")
|
|
sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.")
|
|
sc2_logger.warning(f"text: {r1.text}")
|
|
return "", metadata
|
|
|
|
if (force_download is False) and (metadata == latest_metadata):
|
|
sc2_logger.info("Latest version already installed.")
|
|
return "", metadata
|
|
|
|
sc2_logger.info(f"Attempting to download latest version of API version {api_version} of {repo}.")
|
|
download_url = r1.json()["assets"][0]["browser_download_url"]
|
|
|
|
r2 = requests.get(download_url, headers=headers)
|
|
if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)):
|
|
tempdir = tempfile.gettempdir()
|
|
file = tempdir + os.sep + f"{repo}.zip"
|
|
with open(file, "wb") as fh:
|
|
fh.write(r2.content)
|
|
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
|
|
return file, latest_metadata
|
|
else:
|
|
sc2_logger.warning(f"Status code: {r2.status_code}")
|
|
sc2_logger.warning("Download failed.")
|
|
sc2_logger.warning(f"text: {r2.text}")
|
|
return "", metadata
|
|
|
|
|
|
def cleanup_downloaded_metadata(medatada_json: dict) -> None:
|
|
for asset in medatada_json['assets']:
|
|
del asset['download_count']
|
|
|
|
|
|
def is_mod_update_available(owner: str, repo: str, api_version: str, metadata: str) -> bool:
|
|
import requests
|
|
|
|
headers = {"Accept": 'application/vnd.github.v3+json'}
|
|
url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{api_version}"
|
|
|
|
r1 = requests.get(url, headers=headers)
|
|
if r1.status_code == 200:
|
|
latest_metadata = r1.json()
|
|
cleanup_downloaded_metadata(latest_metadata)
|
|
latest_metadata = str(latest_metadata)
|
|
if metadata != latest_metadata:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
else:
|
|
sc2_logger.warning(f"Failed to reach GitHub while checking for updates.")
|
|
sc2_logger.warning(f"Status code: {r1.status_code}")
|
|
sc2_logger.warning(f"text: {r1.text}")
|
|
return False
|
|
|
|
|
|
def get_location_offset(mission_id):
|
|
return SC2WOL_LOC_ID_OFFSET if mission_id <= SC2Mission.ALL_IN.id \
|
|
else (SC2HOTS_LOC_ID_OFFSET - SC2Mission.ALL_IN.id * VICTORY_MODULO)
|
|
|
|
|
|
def launch():
|
|
colorama.init()
|
|
asyncio.run(main())
|
|
colorama.deinit()
|