diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index dff9a56651..d4c8702da0 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -2,7 +2,7 @@ name: Bug Report
description: File a bug report.
title: "Bug: "
labels:
- - bug
+ - bug / fix
body:
- type: markdown
attributes:
@@ -32,4 +32,4 @@ body:
- Local generation
- While playing
validations:
- required: true
\ No newline at end of file
+ required: true
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index d7cc3c7439..28adb50026 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -18,8 +18,8 @@ jobs:
python-version: 3.9
- name: Install dependencies
run: |
- python -m pip install --upgrade pip
- pip install flake8 pytest
+ python -m pip install --upgrade pip wheel
+ pip install flake8 pytest pytest-subtests
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml
index 1c8ab10c70..4d0ceaec87 100644
--- a/.github/workflows/unittests.yml
+++ b/.github/workflows/unittests.yml
@@ -32,8 +32,8 @@ jobs:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
- python -m pip install --upgrade pip
- pip install flake8 pytest
+ python -m pip install --upgrade pip wheel
+ pip install flake8 pytest pytest-subtests
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |
diff --git a/CommonClient.py b/CommonClient.py
index f830035425..574da16f2a 100644
--- a/CommonClient.py
+++ b/CommonClient.py
@@ -152,8 +152,9 @@ class CommonContext:
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
- missing_locations: typing.Set[int]
+ missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state
+ server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
# internals
@@ -184,8 +185,9 @@ class CommonContext:
self.locations_checked = set() # local state
self.locations_scouted = set()
self.items_received = []
- self.missing_locations = set()
+ self.missing_locations = set() # server state
self.checked_locations = set() # server state
+ self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
self.locations_info = {}
self.input_queue = asyncio.Queue()
@@ -345,6 +347,8 @@ class CommonContext:
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set()
for game in relevant_games:
+ if game not in remote_datepackage_versions:
+ continue
remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game
@@ -632,6 +636,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# when /missing is used for the client side view of what is missing.
ctx.missing_locations = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"])
+ ctx.server_locations = ctx.missing_locations | ctx. checked_locations
elif cmd == 'ReceivedItems':
start_index = args["index"]
diff --git a/Generate.py b/Generate.py
index 1cad836345..d13a78b375 100644
--- a/Generate.py
+++ b/Generate.py
@@ -63,7 +63,7 @@ class PlandoSettings(enum.IntFlag):
def __str__(self) -> str:
if self.value:
- return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value))
+ return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
return "Off"
@@ -84,11 +84,6 @@ def mystery_argparse():
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
- parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
- help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
- parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
- help="Path to the 1.0 JP SM Baserom.")
- parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults["race"])
@@ -183,10 +178,6 @@ def main(args=None, callback=ERmain):
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
- erargs.lttp_rom = args.lttp_rom
- erargs.sm_rom = args.sm_rom
- erargs.enemizercli = args.enemizercli
-
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()}
diff --git a/Launcher.py b/Launcher.py
index 53032ea251..92f43cd26c 100644
--- a/Launcher.py
+++ b/Launcher.py
@@ -10,16 +10,20 @@ Scroll down to components= to add components to the launcher as well as setup.py
import argparse
-from os.path import isfile
-import sys
-from typing import Iterable, Sequence, Callable, Union, Optional
-import subprocess
import itertools
-from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
- is_windows, is_macos, is_linux
-from shutil import which
import shlex
+import subprocess
+import sys
from enum import Enum, auto
+from os.path import isfile
+from shutil import which
+from typing import Iterable, Sequence, Callable, Union, Optional
+
+import ModuleUpdate
+ModuleUpdate.update()
+
+from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
+ is_windows, is_macos, is_linux
def open_host_yaml():
diff --git a/LttPAdjuster.py b/LttPAdjuster.py
index 3de6e3b13a..f516a20ec0 100644
--- a/LttPAdjuster.py
+++ b/LttPAdjuster.py
@@ -752,6 +752,7 @@ class SpriteSelector():
self.window['pady'] = 5
self.spritesPerRow = 32
self.all_sprites = []
+ self.invalid_sprites = []
self.sprite_pool = spritePool
def open_custom_sprite_dir(_evt):
@@ -833,6 +834,13 @@ class SpriteSelector():
self.window.focus()
tkinter_center_window(self.window)
+ if self.invalid_sprites:
+ invalid = sorted(self.invalid_sprites)
+ logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
+ msg = f"{invalid[0]} "
+ msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
+ messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
+
def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename))
self.spritePoolButtons.buttons.remove(button)
@@ -897,7 +905,13 @@ class SpriteSelector():
sprites = []
for file in os.listdir(path):
- sprites.append((file, Sprite(os.path.join(path, file))))
+ if file == '.gitignore':
+ continue
+ sprite = Sprite(os.path.join(path, file))
+ if sprite.valid:
+ sprites.append((file, sprite))
+ else:
+ self.invalid_sprites.append(file)
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
diff --git a/Main.py b/Main.py
index 48095e06bd..acff74595a 100644
--- a/Main.py
+++ b/Main.py
@@ -70,7 +70,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
world.player_name = args.name.copy()
- world.enemizer = args.enemizercli
world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
diff --git a/Options.py b/Options.py
index a4f559a532..7eb108c99d 100644
--- a/Options.py
+++ b/Options.py
@@ -298,7 +298,7 @@ class Toggle(NumericOption):
if type(data) == str:
return cls.from_text(data)
else:
- return cls(data)
+ return cls(int(data))
@classmethod
def get_option_name(cls, value):
diff --git a/SNIClient.py b/SNIClient.py
index aad231691b..3d90fafc17 100644
--- a/SNIClient.py
+++ b/SNIClient.py
@@ -149,8 +149,8 @@ class Context(CommonContext):
def event_invalid_slot(self):
if self.snes_socket is not None and not self.snes_socket.closed:
asyncio.create_task(self.snes_socket.close())
- raise Exception('Invalid ROM detected, '
- 'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
+ raise Exception("Invalid ROM detected, "
+ "please verify that you have loaded the correct rom and reconnect your snes (/snes)")
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -158,7 +158,7 @@ class Context(CommonContext):
if self.rom is None:
self.awaiting_rom = True
snes_logger.info(
- 'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
+ "No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)")
return
self.awaiting_rom = False
self.auth = self.rom
@@ -262,7 +262,7 @@ async def deathlink_kill_player(ctx: Context):
SNES_RECONNECT_DELAY = 5
-# LttP
+# FXPAK Pro protocol memory mapping used by SNI
ROM_START = 0x000000
WRAM_START = 0xF50000
WRAM_SIZE = 0x20000
@@ -293,21 +293,24 @@ SHOP_LEN = (len(Shops.shop_table) * 3) + 5
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
# SM
-SM_ROMNAME_START = 0x007FC0
+SM_ROMNAME_START = ROM_START + 0x007FC0
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
SM_ENDGAME_MODES = {0x26, 0x27}
SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
-SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes
-SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
-SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
+# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue
+SM_RECV_QUEUE_START = SRAM_START + 0x2000
+SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602
+SM_SEND_QUEUE_START = SRAM_START + 0x2700
+SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680
+SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
# SMZ3
-SMZ3_ROMNAME_START = 0x00FFC0
+SMZ3_ROMNAME_START = ROM_START + 0x00FFC0
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
SMZ3_ENDGAME_MODES = {0x26, 0x27}
@@ -1083,6 +1086,9 @@ async def game_watcher(ctx: Context):
if ctx.awaiting_rom:
await ctx.server_auth(False)
+ elif ctx.server is None:
+ snes_logger.warning("ROM detected but no active multiworld server connection. " +
+ "Connect using command: /connect server:port")
if ctx.auth and ctx.auth != ctx.rom:
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
@@ -1159,6 +1165,9 @@ async def game_watcher(ctx: Context):
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
await track_locations(ctx, roomid, roomdata)
elif ctx.game == GAME_SM:
+ if ctx.server is None or ctx.slot is None:
+ # not successfully connected to a multiworld server, cannot process the game sending items
+ continue
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
currently_dead = gamemode[0] in SM_DEATH_MODES
@@ -1169,25 +1178,25 @@ async def game_watcher(ctx: Context):
ctx.finished_game = True
continue
- data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x680, 4)
+ data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4)
if data is None:
continue
recv_index = data[0] | (data[1] << 8)
- recv_item = data[2] | (data[3] << 8)
+ recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT
while (recv_index < recv_item):
itemAdress = recv_index * 8
- message = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
+ message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8)
# worldId = message[0] | (message[1] << 8) # unused
# itemId = message[2] | (message[3] << 8) # unused
itemIndex = (message[4] | (message[5] << 8)) >> 3
recv_index += 1
- snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680,
+ snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT,
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
- from worlds.sm.Locations import locations_start_id
+ from worlds.sm import locations_start_id
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
@@ -1196,15 +1205,14 @@ async def game_watcher(ctx: Context):
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
- data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
+ data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2)
if data is None:
continue
- # recv_itemOutPtr = data[0] | (data[1] << 8) # unused
- itemOutPtr = data[2] | (data[3] << 8)
+ itemOutPtr = data[0] | (data[1] << 8)
- from worlds.sm.Items import items_start_id
- from worlds.sm.Locations import locations_start_id
+ from worlds.sm import items_start_id
+ from worlds.sm import locations_start_id
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
@@ -1214,10 +1222,10 @@ async def game_watcher(ctx: Context):
locationId = 0x00 #backward compat
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
- snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
+ snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes(
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
itemOutPtr += 1
- snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
+ snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT,
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names[item.item], 'red', 'bold'),
@@ -1225,6 +1233,9 @@ async def game_watcher(ctx: Context):
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx)
elif ctx.game == GAME_SMZ3:
+ if ctx.server is None or ctx.slot is None:
+ # not successfully connected to a multiworld server, cannot process the game sending items
+ continue
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
if (currentGame is not None):
if (currentGame[0] != 0):
diff --git a/Starcraft2Client.py b/Starcraft2Client.py
index dc63e9a456..b8f6086914 100644
--- a/Starcraft2Client.py
+++ b/Starcraft2Client.py
@@ -1,31 +1,31 @@
from __future__ import annotations
-import multiprocessing
-import logging
import asyncio
+import copy
+import ctypes
+import logging
+import multiprocessing
import os.path
+import re
+import sys
+import typing
+import queue
+from pathlib import Path
import nest_asyncio
import sc2
-
-from sc2.main import run_game
-from sc2.data import Race
from sc2.bot_ai import BotAI
+from sc2.data import Race
+from sc2.main import run_game
from sc2.player import Bot
-from worlds.sc2wol.Regions import MissionInfo
-from worlds.sc2wol.MissionTables import lookup_id_to_mission
+from MultiServer import mark_raw
+from Utils import init_logging, is_windows
+from worlds.sc2wol import SC2WoLWorld
from worlds.sc2wol.Items import lookup_id_to_name, item_table
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
-from worlds.sc2wol import SC2WoLWorld
-
-from pathlib import Path
-import re
-from MultiServer import mark_raw
-import ctypes
-import sys
-
-from Utils import init_logging, is_windows
+from worlds.sc2wol.MissionTables import lookup_id_to_mission
+from worlds.sc2wol.Regions import MissionInfo
if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client")
@@ -35,10 +35,12 @@ sc2_logger = logging.getLogger("Starcraft2")
import colorama
-from NetUtils import *
+from NetUtils import ClientStatus, RawJSONtoTextParser
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
nest_asyncio.apply()
+max_bonus: int = 8
+victory_modulo: int = 100
class StarcraftClientProcessor(ClientCommandProcessor):
@@ -98,13 +100,13 @@ class StarcraftClientProcessor(ClientCommandProcessor):
def _cmd_available(self) -> bool:
"""Get what missions are currently available to play"""
- request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
+ 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.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
+ request_unfinished_missions(self.ctx)
return True
@mark_raw
@@ -125,18 +127,19 @@ class SC2Context(CommonContext):
items_handling = 0b111
difficulty = -1
all_in_choice = 0
- mission_req_table = None
- items_rec_to_announce = []
- rec_announce_pos = 0
- items_sent_to_announce = []
- sent_announce_pos = 0
- announcements = []
- announcement_pos = 0
+ mission_req_table: typing.Dict[str, MissionInfo] = {}
+ announcements = queue.Queue()
sc2_run_task: typing.Optional[asyncio.Task] = None
- missions_unlocked = False
+ missions_unlocked: bool = False # allow launching missions ignoring requirements
current_tooltip = None
last_loc_list = None
difficulty_override = -1
+ mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {}
+ raw_text_parser: RawJSONtoTextParser
+
+ def __init__(self, *args, **kwargs):
+ super(SC2Context, self).__init__(*args, **kwargs)
+ self.raw_text_parser = RawJSONtoTextParser(self)
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -149,30 +152,32 @@ class SC2Context(CommonContext):
self.difficulty = args["slot_data"]["game_difficulty"]
self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"]
- self.mission_req_table = {}
- # Compatibility for 0.3.2 server data.
- if "category" not in next(iter(slot_req_table)):
- for i, mission_data in enumerate(slot_req_table.values()):
- mission_data["category"] = wol_default_categories[i]
- for mission in slot_req_table:
- self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
+ self.mission_req_table = {
+ mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
+ }
+
+ self.build_location_to_mission_mapping()
# Look for and set SC2PATH.
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
if "SC2PATH" not in os.environ and check_game_install_path():
check_mod_install()
- if cmd in {"PrintJSON"}:
- if "receiving" in args:
- if self.slot_concerns_self(args["receiving"]):
- self.announcements.append(args["data"])
- return
- if "item" in args:
- if self.slot_concerns_self(args["item"].player):
- self.announcements.append(args["data"])
+ def on_print_json(self, args: dict):
+ if "receiving" in args and self.slot_concerns_self(args["receiving"]):
+ relevant = True
+ elif "item" in args and self.slot_concerns_self(args["item"].player):
+ relevant = True
+ 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):
- from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
+ from kvui import GameManager, HoverBehavior, ServerToolTip
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.tabbedpanel import TabbedPanelItem
@@ -190,6 +195,7 @@ class SC2Context(CommonContext):
class MissionButton(HoverableButton):
tooltip_text = StringProperty("Test")
+ ctx: SC2Context
def __init__(self, *args, **kwargs):
super(HoverableButton, self).__init__(*args, **kwargs)
@@ -210,10 +216,7 @@ class SC2Context(CommonContext):
self.ctx.current_tooltip = self.layout
def on_leave(self):
- if self.ctx.current_tooltip:
- App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
-
- self.ctx.current_tooltip = None
+ self.ctx.ui.clear_tooltip()
@property
def ctx(self) -> CommonContext:
@@ -235,13 +238,20 @@ class SC2Context(CommonContext):
mission_panel = None
last_checked_locations = {}
mission_id_to_button = {}
- launching = False
+ launching: typing.Union[bool, int] = False # if int -> mission ID
refresh_from_launching = True
first_check = True
+ ctx: SC2Context
def __init__(self, ctx):
super().__init__(ctx)
+ def clear_tooltip(self):
+ if self.ctx.current_tooltip:
+ App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
+
+ self.ctx.current_tooltip = None
+
def build(self):
container = super().build()
@@ -256,7 +266,7 @@ class SC2Context(CommonContext):
def build_mission_table(self, dt):
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
- not self.refresh_from_launching)) or self.first_check:
+ not self.refresh_from_launching)) or self.first_check:
self.refresh_from_launching = True
self.mission_panel.clear_widgets()
@@ -267,12 +277,7 @@ class SC2Context(CommonContext):
self.mission_id_to_button = {}
categories = {}
- available_missions = []
- unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
- unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
- self.ctx.mission_req_table,
- self.ctx, available_missions=available_missions,
- unfinished_locations=unfinished_locations)
+ available_missions, unfinished_missions = calc_unfinished_missions(self.ctx)
# separate missions into categories
for mission in self.ctx.mission_req_table:
@@ -283,7 +288,8 @@ class SC2Context(CommonContext):
for category in categories:
category_panel = MissionCategory()
- category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
+ category_panel.add_widget(
+ Label(text=category, size_hint_y=None, height=50, outline_width=1))
# Map is completed
for mission in categories[category]:
@@ -295,7 +301,9 @@ class SC2Context(CommonContext):
text = f"[color=6495ED]{text}[/color]"
tooltip = f"Uncollected locations:\n"
- tooltip += "\n".join(location for location in unfinished_locations[mission])
+ tooltip += "\n".join([self.ctx.location_names[loc] for loc in
+ self.ctx.locations_for_mission(mission)
+ if loc in self.ctx.missing_locations])
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
@@ -303,7 +311,7 @@ class SC2Context(CommonContext):
text = f"[color=a9a9a9]{text}[/color]"
tooltip = f"Requires: "
if len(self.ctx.mission_req_table[mission].required_world) > 0:
- tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
+ tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
req_mission in
self.ctx.mission_req_table[mission].required_world)
@@ -325,13 +333,17 @@ class SC2Context(CommonContext):
self.refresh_from_launching = False
self.mission_panel.clear_widgets()
- self.mission_panel.add_widget(Label(text="Launching Mission"))
+ self.mission_panel.add_widget(Label(text="Launching Mission: " +
+ lookup_id_to_mission[self.launching]))
+ if self.ctx.ui:
+ self.ctx.ui.clear_tooltip()
def mission_callback(self, button):
if not self.launching:
- self.ctx.play_mission(list(self.mission_id_to_button.keys())
- [list(self.mission_id_to_button.values()).index(button)])
- self.launching = True
+ mission_id: int = list(self.mission_id_to_button.values()).index(button)
+ self.ctx.play_mission(list(self.mission_id_to_button)
+ [mission_id])
+ self.launching = mission_id
Clock.schedule_once(self.finish_launching, 10)
def finish_launching(self, dt):
@@ -349,7 +361,7 @@ class SC2Context(CommonContext):
def play_mission(self, mission_id):
if self.missions_unlocked or \
- is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
+ 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!")
@@ -358,12 +370,29 @@ class SC2Context(CommonContext):
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")
+ name="Starcraft 2 Launch")
else:
sc2_logger.info(
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
f"Use /unfinished or /available to see what is available.")
+ def build_location_to_mission_mapping(self):
+ mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = {
+ mission_info.id: set() for mission_info in self.mission_req_table.values()
+ }
+
+ for loc in self.server_locations:
+ mission_id, objective = divmod(loc - SC2WOL_LOC_ID_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: str):
+ mission_id: int = self.mission_req_table[mission].id
+ objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id]
+ for objective in objectives:
+ yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective
+
async def main():
multiprocessing.freeze_support()
@@ -459,11 +488,7 @@ def calc_difficulty(difficulty):
return 'X'
-async def starcraft_launch(ctx: SC2Context, mission_id):
- ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
- ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
- ctx.announcements_pos = len(ctx.announcements)
-
+async def starcraft_launch(ctx: SC2Context, mission_id: int):
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
with DllDirectory(None):
@@ -472,32 +497,29 @@ async def starcraft_launch(ctx: SC2Context, mission_id):
class ArchipelagoBot(sc2.bot_ai.BotAI):
- game_running = False
- mission_completed = False
- first_bonus = False
- second_bonus = False
- third_bonus = False
- fourth_bonus = False
- fifth_bonus = False
- sixth_bonus = False
- seventh_bonus = False
- eight_bonus = False
- ctx: SC2Context = None
- mission_id = 0
+ game_running: bool = False
+ mission_completed: bool = False
+ boni: typing.List[bool]
+ setup_done: bool
+ ctx: SC2Context
+ mission_id: int
can_read_game = False
- last_received_update = 0
+ last_received_update: int = 0
def __init__(self, ctx: SC2Context, mission_id):
+ self.setup_done = False
self.ctx = ctx
self.mission_id = mission_id
+ self.boni = [False for _ in range(max_bonus)]
super(ArchipelagoBot, self).__init__()
async def on_step(self, iteration: int):
game_state = 0
- if iteration == 0:
+ if not self.setup_done:
+ self.setup_done = True
start_items = calculate_items(self.ctx.items_received)
if self.ctx.difficulty_override >= 0:
difficulty = calc_difficulty(self.ctx.difficulty_override)
@@ -511,36 +533,10 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
self.last_received_update = len(self.ctx.items_received)
else:
- if self.ctx.announcement_pos < len(self.ctx.announcements):
- index = 0
- message = ""
- while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
- message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
- index += 1
-
- index = 0
- start_rem_pos = -1
- # Remove unneeded [Color] tags
- while index < len(message):
- if message[index] == '[':
- start_rem_pos = index
- index += 1
- elif message[index] == ']' and start_rem_pos > -1:
- temp_msg = ""
-
- if start_rem_pos > 0:
- temp_msg = message[:start_rem_pos]
- if index < len(message) - 1:
- temp_msg += message[index + 1:]
-
- message = temp_msg
- index += start_rem_pos - index
- start_rem_pos = -1
- else:
- index += 1
-
+ if not self.ctx.announcements.empty():
+ message = self.ctx.announcements.get(timeout=1)
await self.chat_send("SendMessage " + message)
- self.ctx.announcement_pos += 1
+ self.ctx.announcements.task_done()
# Archipelago reads the health
for unit in self.all_own_units():
@@ -568,169 +564,97 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
if game_state & (1 << 1) and not self.mission_completed:
if self.mission_id != 29:
print("Mission Completed")
- await self.ctx.send_msgs([
- {"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
+ await self.ctx.send_msgs(
+ [{"cmd": 'LocationChecks',
+ "locations": [SC2WOL_LOC_ID_OFFSET + 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
- if game_state & (1 << 2) and not self.first_bonus:
- print("1st Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
- self.first_bonus = True
-
- if not self.second_bonus and game_state & (1 << 3):
- print("2nd Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
- self.second_bonus = True
-
- if not self.third_bonus and game_state & (1 << 4):
- print("3rd Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
- self.third_bonus = True
-
- if not self.fourth_bonus and game_state & (1 << 5):
- print("4th Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
- self.fourth_bonus = True
-
- if not self.fifth_bonus and game_state & (1 << 6):
- print("5th Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
- self.fifth_bonus = True
-
- if not self.sixth_bonus and game_state & (1 << 7):
- print("6th Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
- self.sixth_bonus = True
-
- if not self.seventh_bonus and game_state & (1 << 8):
- print("6th Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
- self.seventh_bonus = True
-
- if not self.eight_bonus and game_state & (1 << 9):
- print("6th Bonus Collected")
- await self.ctx.send_msgs(
- [{"cmd": 'LocationChecks',
- "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
- self.eight_bonus = 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": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}])
+ self.boni[x] = True
else:
await self.chat_send("LostConnection - Lost connection to game.")
-def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
- objectives_complete = 0
-
- if missions_info[mission].extra_locations > 0:
- for i in range(missions_info[mission].extra_locations):
- if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
- objectives_complete += 1
- else:
- unfinished_locations[mission].append(ctx.location_names[
- missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
-
- return objectives_complete
-
- else:
- return -1
-
-
-def request_unfinished_missions(locations_done, location_table, ui, ctx):
- if location_table:
+def request_unfinished_missions(ctx: SC2Context):
+ if ctx.mission_req_table:
message = "Unfinished Missions: "
- unlocks = initialize_blank_mission_dict(location_table)
- unfinished_locations = initialize_blank_mission_dict(location_table)
+ unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
+ unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table)
- unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
- unfinished_locations=unfinished_locations)
+ _, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
- message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
+ message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
mark_up_objectives(
- f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
+ 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 ui:
- ui.log_panels['All'].on_message_markup(message)
- ui.log_panels['Starcraft2'].on_message_markup(message)
+ 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(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
- available_missions=[]):
+def calc_unfinished_missions(ctx: SC2Context, unlocks=None):
unfinished_missions = []
locations_completed = []
if not unlocks:
- unlocks = initialize_blank_mission_dict(locations)
+ unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
- if not unfinished_locations:
- unfinished_locations = initialize_blank_mission_dict(locations)
-
- if len(available_missions) > 0:
- available_missions = []
-
- available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
+ available_missions = calc_available_missions(ctx, unlocks)
for name in available_missions:
- if not locations[name].extra_locations == -1:
- objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
-
- if objectives_completed < locations[name].extra_locations:
+ 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:
+ else: # infer that this is the final mission as it has no objectives
unfinished_missions.append(name)
locations_completed.append(-1)
- return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
+ return available_missions, dict(zip(unfinished_missions, locations_completed))
-def is_mission_available(mission_id_to_check, locations_done, locations):
- unfinished_missions = calc_available_missions(locations_done, locations)
+def is_mission_available(ctx: SC2Context, mission_id_to_check):
+ unfinished_missions = calc_available_missions(ctx)
- return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
+ return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions)
-def mark_up_mission_name(mission, location_table, ui, unlock_table):
+def mark_up_mission_name(ctx: SC2Context, mission, unlock_table):
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
- if location_table[mission].completion_critical:
- if ui:
+ if ctx.mission_req_table[mission].completion_critical:
+ if ctx.ui:
message = "[color=AF99EF]" + mission + "[/color]"
else:
message = "*" + mission + "*"
else:
message = mission
- if ui:
+ if ctx.ui:
unlocks = unlock_table[mission]
if len(unlocks) > 0:
- pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
- pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
+ pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: "
+ pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks)
pre_message += f"]"
message = pre_message + message + "[/ref]"
@@ -743,7 +667,7 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
if ctx.ui:
locations = unfinished_locations[mission]
- pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
+ pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|"
pre_message += "
".join(location for location in locations)
pre_message += f"]"
formatted_message = pre_message + message + "[/ref]"
@@ -751,90 +675,91 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
return formatted_message
-def request_available_missions(locations_done, location_table, ui):
- if location_table:
+def request_available_missions(ctx: SC2Context):
+ if ctx.mission_req_table:
message = "Available Missions: "
# Initialize mission unlock table
- unlocks = initialize_blank_mission_dict(location_table)
+ unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
- missions = calc_available_missions(locations_done, location_table, unlocks)
+ missions = calc_available_missions(ctx, unlocks)
message += \
- ", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
+ ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
+ f"[{ctx.mission_req_table[mission].id}]"
for mission in missions)
- if ui:
- ui.log_panels['All'].on_message_markup(message)
- ui.log_panels['Starcraft2'].on_message_markup(message)
+ 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(locations_done, locations, unlocks=None):
+def calc_available_missions(ctx: SC2Context, unlocks=None):
available_missions = []
missions_complete = 0
# Get number of missions completed
- for loc in locations_done:
- if loc % 100 == 0:
+ for loc in ctx.checked_locations:
+ if loc % victory_modulo == 0:
missions_complete += 1
- for name in locations:
+ for name in ctx.mission_req_table:
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
if unlocks:
- for unlock in locations[name].required_world:
- unlocks[list(locations)[unlock-1]].append(name)
+ for unlock in ctx.mission_req_table[name].required_world:
+ unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name)
- if mission_reqs_completed(name, missions_complete, locations_done, locations):
+ if mission_reqs_completed(ctx, name, missions_complete):
available_missions.append(name)
return available_missions
-def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
+def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
"""Returns a bool signifying if the mission has all requirements complete and can be done
- Keyword arguments:
+ 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
- locations_done -- a list of the location ids that have been complete
- locations -- a dict of MissionInfo for mission requirements for this world"""
- if len(locations[location_to_check].required_world) >= 1:
+"""
+ if len(ctx.mission_req_table[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 locations[location_to_check].required_world:
+ for req_mission in ctx.mission_req_table[mission_name].required_world:
req_success = True
# Check if required mission has been completed
- if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
- if not locations[location_to_check].or_requirements:
+ if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id *
+ victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations:
+ if not ctx.mission_req_table[mission_name].or_requirements:
return False
else:
req_success = False
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
- if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
- locations):
- if not locations[location_to_check].or_requirements:
+ if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
+ if not ctx.mission_req_table[mission_name].or_requirements:
return False
else:
req_success = False
# If requirement check succeeded mark or as satisfied
- if locations[location_to_check].or_requirements and req_success:
+ if ctx.mission_req_table[mission_name].or_requirements and req_success:
or_success = True
- if locations[location_to_check].or_requirements:
+ if ctx.mission_req_table[mission_name].or_requirements:
# Return false if or requirements not met
if not or_success:
return False
# Check number of missions
- if missions_complete >= locations[location_to_check].number:
+ if missions_complete >= ctx.mission_req_table[mission_name].number:
return True
else:
return False
@@ -929,7 +854,7 @@ class DllDirectory:
self.set(self._old)
@staticmethod
- def get() -> str:
+ def get() -> typing.Optional[str]:
if sys.platform == "win32":
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
buf = ctypes.create_unicode_buffer(n)
diff --git a/Utils.py b/Utils.py
index c621e31c9a..c362131d75 100644
--- a/Utils.py
+++ b/Utils.py
@@ -35,7 +35,7 @@ class Version(typing.NamedTuple):
build: int
-__version__ = "0.3.4"
+__version__ = "0.3.5"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -619,7 +619,7 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset
def sorter(element: str) -> str:
parts = element.split(maxsplit=1)
if parts[0].lower() in ignore:
- return parts[1]
+ return parts[1].lower()
else:
- return element
+ return element.lower()
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
diff --git a/WebHost.py b/WebHost.py
index db802193a6..4c07e8b185 100644
--- a/WebHost.py
+++ b/WebHost.py
@@ -12,7 +12,7 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn
import Utils
-Utils.local_path.cached_path = os.path.dirname(__file__)
+Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
from WebHostLib import register, app as raw_app
from waitress import serve
@@ -104,7 +104,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
- sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"].lower())
+ sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data
diff --git a/WebHostLib/templates/supportedGames.html b/WebHostLib/templates/supportedGames.html
index fe81463a46..82f6348db2 100644
--- a/WebHostLib/templates/supportedGames.html
+++ b/WebHostLib/templates/supportedGames.html
@@ -1,7 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
-