Files
dockipelago/worlds/hades/Client.py
Jonathan Tinney 7971961166
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

429 lines
19 KiB
Python

import os
import sys
import asyncio
import threading
import importlib.util
import Utils
import pathlib
from typing import Dict, NamedTuple, Optional
from NetUtils import ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
import settings
# --------------------- Styx Scribe useful globals -----------------
global subsume
styx_scribe_recieve_prefix = "Polycosmos to Client:"
styx_scribe_send_prefix = "Client to Polycosmos:"
# --------------------- Styx Scribe useful globals -----------------
# Here we implement methods for the client
class HadesClientCommandProcessor(ClientCommandProcessor):
def _cmd_resync(self):
"""Manually trigger a resync."""
#This is a really stupid solution, but it works so idk
Utils.async_start(self.ctx.check_connection_and_send_items_and_request_starting_info(""))
class HadesContext(CommonContext):
# ----------------- Client start up and ending section starts --------------------------------
command_processor = HadesClientCommandProcessor
game = "Hades"
items_handling = 0b111 # full remote
polycosmos_version = "0.15"
is_connected : bool
deathlink_pending : bool
deathlink_enabled : bool
creating_location_to_item_mapping : bool
is_receiving_items_from_connect_package : bool
location_name_to_id : dict
def __init__(self, server_address: Optional[str] = None, password: Optional[str] = None):
super(HadesContext, self).__init__(server_address, password)
self.hades_slot_data = None
self.is_connected = False
self.deathlink_pending = False
self.deathlink_enabled = False
self.creating_location_to_item_mapping = False
self.is_receiving_items_from_connect_package = False
self.missing_locations_cache = []
self.checked_locations_cache = []
# Add hook to comunicate with StyxScribe
subsume.AddHook(self.send_location_check_to_server, styx_scribe_recieve_prefix + "Locations updated:",
"HadesClient")
subsume.AddHook(self.on_run_completion, styx_scribe_recieve_prefix + "Hades defeated", "HadesClient")
subsume.AddHook(self.check_connection_and_send_items_and_request_starting_info,
styx_scribe_recieve_prefix + "Data requested", "HadesClient")
# hook to send deathlink to other player when Zag dies
subsume.AddHook(self.send_death, styx_scribe_recieve_prefix + "Zag died", "HadesClient")
subsume.AddHook(self.send_location_hint_to_server, styx_scribe_recieve_prefix \
+ "Locations hinted:", "HadesClient")
async def server_auth(self, password_requested: bool = False) -> None:
# This is called to autentificate with the server.
if password_requested and not self.password:
await super(HadesContext, self).server_auth(password_requested)
await self.get_username()
self.tags = set()
await self.send_connect()
async def connection_closed(self) -> None:
# This is called when the connection is closed (duh!)
# This will send the message always, but only process by Styx scribe if actually in game
subsume.Send(styx_scribe_send_prefix + "Connection Error")
self.is_connected = False
self.is_receiving_items_from_connect_package = False
await super(HadesContext, self).connection_closed()
# Do not touch this
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
# What is called when the app gets shutdown
subsume.close()
await super(HadesContext, self).shutdown()
# ----------------- Client start up and ending section end --------------------------------
# ----------------- Package Management section starts --------------------------------
def on_package(self, cmd: str, args: dict) -> None:
# This is what is done when a package arrives.
if cmd == "Connected":
# What should be done in a connection package
self.hades_slot_data = args["slot_data"]
if not (self.hades_slot_data["version_check"] == self.polycosmos_version):
stringError = "WORLD GENERATED WITH POLYCOSMOS " + self.hades_slot_data["version_check"] \
+ " AND CLIENT USING POLYCOSMOS " + self.polycosmos_version + "\n"
stringError += "THESE ARE NOT COMPATIBLE"
raise Exception(stringError)
self.location_name_to_id = self.get_location_name_to_id()
if "death_link" in self.hades_slot_data and self.hades_slot_data["death_link"]:
Utils.async_start(self.update_death_link(True))
self.deathlink_enabled = True
self.is_connected = True
self.is_receiving_items_from_connect_package = True
if cmd == "RoomInfo":
# What should be done when room info is sent.
self.seed_name = args["seed_name"]
if cmd == "RoomUpdate":
if "checked_lodations" in args and len(args["checked_locations"]) > 0:
subsume.Send(styx_scribe_send_prefix + "Locations collected:" + "-".join(
(location) for location in args["checked_locations"]
))
if cmd == "ReceivedItems":
# We ignore sending the package to hades if just connected,
# since the game is not ready for it (and will request it itself later)
if self.is_receiving_items_from_connect_package:
return
self.send_items()
if cmd == "LocationInfo":
if self.creating_location_to_item_mapping:
self.creating_location_to_item_mapping = False
self.create_location_to_item_dictionary(args["locations"])
return
super().on_package(cmd, args)
if cmd == "Bounced":
if "tags" in args:
if "DeathLink" in args["tags"]:
self.on_deathlink(args["data"])
def send_items(self) -> None:
payload_message = ",".join(self.item_names.lookup_in_game(item.item) for item in self.items_received)
subsume.Send(styx_scribe_send_prefix + "Items Updated:" + payload_message)
async def send_location_check_to_server(self, message : str) -> None:
if (self.location_name_to_id):
await self.check_locations([self.location_name_to_id[message]])
async def check_connection_and_send_items_and_request_starting_info(self, message : str) -> None:
if self.check_for_connection():
self.is_receiving_items_from_connect_package = False
# send items that were already cached in connect
self.send_items()
self.request_location_to_item_dictionary()
def store_settings_data(self) -> None:
hades_settings_string = ""
#codify in the string all heat settings
hades_settings_string += str(self.hades_slot_data["heat_system"]) + "-"
hades_settings_string += str(self.hades_slot_data["hard_labor_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["lasting_consequences_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["convenience_fee_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["jury_summons_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["extreme_measures_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["calisthenics_program_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["benefits_package_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["middle_management_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["underworld_customs_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["forced_overtime_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["heightened_security_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["routine_inspection_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["damage_control_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["approval_process_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["tight_deadline_pact_amount"]) + "-"
hades_settings_string += str(self.hades_slot_data["personal_liability_pact_amount"]) + "-"
#Codify in the string all the fillers values
hades_settings_string += str(self.hades_slot_data["darkness_pack_value"]) + "-"
hades_settings_string += str(self.hades_slot_data["keys_pack_value"]) + "-"
hades_settings_string += str(self.hades_slot_data["gemstones_pack_value"]) + "-"
hades_settings_string += str(self.hades_slot_data["diamonds_pack_value"]) + "-"
hades_settings_string += str(self.hades_slot_data["titan_blood_pack_value"]) + "-"
hades_settings_string += str(self.hades_slot_data["nectar_pack_value"]) + "-"
hades_settings_string += str(self.hades_slot_data["ambrosia_pack_value"]) + "-"
#Codify in the string all game settings
hades_settings_string += str(self.hades_slot_data["location_system"]) + "-"
hades_settings_string += str(self.hades_slot_data["reverse_order_em"]) + "-"
hades_settings_string += str(self.hades_slot_data["keepsakesanity"]) + "-"
hades_settings_string += str(self.hades_slot_data["weaponsanity"]) + "-"
hades_settings_string += str(self.hades_slot_data["storesanity"]) + "-"
hades_settings_string += str(self.hades_slot_data["initial_weapon"]) + "-"
hades_settings_string += str(self.hades_slot_data["ignore_greece_deaths"]) + "-"
hades_settings_string += str(self.hades_slot_data["fatesanity"]) + "-"
hades_settings_string += str(self.hades_slot_data["hidden_aspectsanity"]) + "-"
hades_settings_string += str(self.polycosmos_version) + "-"
hades_settings_string += str(self.hades_slot_data["automatic_rooms_finish_on_hades_defeat"]) + "-"
#Codify in the string all the finishing conditions
hades_settings_string += str(self.hades_slot_data["hades_defeats_needed"]) + "-"
hades_settings_string += str(self.hades_slot_data["weapons_clears_needed"]) + "-"
hades_settings_string += str(self.hades_slot_data["keepsakes_needed"]) + "-"
hades_settings_string += str(self.hades_slot_data["fates_needed"]) + "-"
return hades_settings_string
def request_location_to_item_dictionary(self) -> None:
self.creating_location_to_item_mapping = True
request = self.server_locations
Utils.async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": request, "create_as_hint": 0}]))
def create_location_to_item_dictionary(self, itemsdict : Optional[dict]) -> None:
locationItemMapping = ""
for networkitem in itemsdict:
location = self.parse_to_len_encode(self.location_names.lookup_in_slot(networkitem.location))
player_name = self.parse_to_len_encode(self.player_names[networkitem.player])
item_name = self.parse_to_len_encode(self.item_names.lookup_in_slot(networkitem.item, networkitem.player))
locationItemMapping += self.parse_to_len_encode(location+player_name+item_name)
subsume.Send(styx_scribe_send_prefix + "Location to Item Map:" + locationItemMapping)
subsume.Send(styx_scribe_send_prefix + "Data finished" + self.store_settings_data())
def parse_to_len_encode(self, inputstring: str) -> str:
output = self.clear_invalid_char(inputstring)
return str(len(output)) + "|" + output
def obtain_array_from_len_encode(self, inputstring: str) -> list:
result = []
if (inputstring == ""):
return result
run_index = 0
while run_index < len(inputstring):
sep_index = 0
for i in range(run_index, len(inputstring)):
char_index = inputstring[i]
if (char_index == "|"):
sep_index = i
break
if (run_index == sep_index):
break
lenmessage = int(inputstring[run_index : sep_index])
word = inputstring[sep_index + 1 : sep_index + lenmessage + 1]
result.append(word)
run_index = sep_index + lenmessage + 1
return result
def clear_invalid_char(self, inputstring: str) -> str:
newstr = inputstring.replace("{", "")
newstr = newstr.replace("}", "")
newstr = newstr.encode("ascii", "replace").decode(encoding="utf-8", errors="ignore")
return newstr
# ----------------- Package Management section ends --------------------------------
# ----------------- Hints from game section starts --------------------------------
async def send_location_hint_to_server(self, message : str) -> None:
if self.hades_slot_data["store_give_hints"] == 0:
return
split_array = self.obtain_array_from_len_encode(message)
request = []
for location in split_array:
if len(location) > 0:
request.append(self.location_name_to_id[location])
Utils.async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": request, "create_as_hint": 2}]))
# ----------------- Hints from game section ends ------------------------
# -------------deathlink section started --------------------------------
def on_deathlink(self, data: dict) -> None:
# What should be done when a deathlink message is recieved
if self.deathlink_pending:
return
self.deathlink_pending = True
subsume.Send(styx_scribe_send_prefix + "Deathlink received")
super().on_deathlink(data)
Utils.async_start(self.wait_and_lower_deathlink_flag())
def send_death(self, death_text: str = "") -> None:
# What should be done to send a death link
# Avoid sending death if we died from a deathlink
if self.deathlink_pending or not self.deathlink_enabled:
return
self.deathlink_pending = True
Utils.async_start(super().send_death(death_text))
Utils.async_start(self.wait_and_lower_deathlink_flag())
async def wait_and_lower_deathlink_flag(self) -> None:
await asyncio.sleep(3)
self.deathlink_pending = False
# -------------deathlink section ended
# -------------game completion section starts
# this is to detect game completion. Note that on futher updates this will need --------------------------------
# to be changed to adapt to new game completion conditions
def on_run_completion(self, message : str) -> None:
#parse message
counters = message.split("-")
#counters[0] is number of clears, counters[1] is number of different weapons with runs clears.
hasEnoughRuns = self.hades_slot_data["hades_defeats_needed"] <= int(counters[0])
hasEnoughWeapons = self.hades_slot_data["weapons_clears_needed"] <= int(counters[1])
hasEnoughKeepsakes = self.hades_slot_data["keepsakes_needed"] <= int(counters[2])
hasEnoughFates = self.hades_slot_data["fates_needed"] <= int(counters[3])
if hasEnoughRuns and hasEnoughWeapons and hasEnoughKeepsakes and hasEnoughFates:
Utils.async_start(self.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]))
self.finished_game = True
# -------------game completion section ended --------------------------------
# ------------ game connection QoL handling
def check_for_connection(self) -> bool:
if not self.is_connected:
subsume.Send(styx_scribe_send_prefix + "Connection Error")
return False
return True
# ------------ Helper method to invert lookup table. Can erase if AP has its own internal one.
def get_location_name_to_id(self):
table = {}
for locationid in self.server_locations:
table[self.location_names.lookup_in_slot(locationid)] = locationid
return table
# ------------ gui section ------------------------------------------------
def run_gui(self) -> None:
from kvui import GameManager
class HadesManager(GameManager):
# logging_pairs for any separate logging tabs
base_title = "Archipelago Hades Client"
self.ui = HadesManager(self)
self.ui_task = Utils.async_start(self.ui.async_run(), name="UI")
# ------------ Methods to start the client + Hades + StyxScribe ------------
def launch_hades():
subsume.Launch(True, None)
def launch():
async def main(args):
ctx = HadesContext(args.connect, args.password)
ctx.server_task = Utils.async_start(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
import colorama
# --------------------- Styx Scribe initialization -----------------
styx_scribe_path = settings.get_settings()["hades_options"]["styx_scribe_path"]
# Parsing of styxscribe path. This will try to find it on the same folder if it fails.
last_slash = styx_scribe_path.rfind("/")
if (last_slash == -1):
print("Invalid path given to hades client for styxscribe. Cant parse")
return
filesbstr = styx_scribe_path[last_slash + 1 :]
if (filesbstr != "StyxScribe.py"):
print("Path given does not correspond to StyxScribe. Attempting to parse")
styx_scribe_path = styx_scribe_path[:last_slash + 1] + "StyxScribe.py"
hadespath = os.path.dirname(styx_scribe_path)
if (not os.path.exists(hadespath)):
print("Styx scribe not found at path.")
spec = importlib.util.spec_from_file_location("StyxScribe", str(styx_scribe_path))
styx_scribe = importlib.util.module_from_spec(spec)
sys.modules["StyxScribe"] = styx_scribe
spec.loader.exec_module(styx_scribe)
global subsume
subsume = styx_scribe.StyxScribe("Hades")
# hack to make it work without chdir
subsume.proxy_purepaths = {
None: hadespath / subsume.executable_cwd_purepath / styx_scribe.LUA_PROXY_STDIN,
False: hadespath / subsume.executable_cwd_purepath / styx_scribe.LUA_PROXY_FALSE,
True: hadespath / subsume.executable_cwd_purepath / styx_scribe.LUA_PROXY_TRUE
}
subsume.args[0] = os.path.normpath(os.path.join(hadespath, subsume.args[0]))
subsume.executable_purepath = pathlib.PurePath(hadespath, subsume.executable_purepath)
for i in range(len(subsume.plugins_paths)):
subsume.plugins_paths[i] = pathlib.PurePath(hadespath, subsume.plugins_paths[i])
subsume.LoadPlugins()
# --------------------- Styx Scribe initialization -----------------
thr = threading.Thread(target=launch_hades, args=(), kwargs={})
thr.start()
parser = get_base_parser()
args = parser.parse_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()