Files
Manual-Cogmind-Archipelago/ManualClient.py
T
2026-10-10 17:05:45 +02:00

1548 lines
79 KiB
Python

from __future__ import annotations
import asyncio
from functools import cache
import os
import re
import sys
import time
import typing
from typing import Any, Dict, List, Optional
from enum import IntEnum
import requests
from worlds import AutoWorldRegister, network_data_package
from worlds.LauncherComponents import icon_paths
import json
import traceback
import ModuleUpdate
ModuleUpdate.update()
import Utils
from NetUtils import ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, server_loop
from MultiServer import mark_raw
tracker_loaded = False
try:
from worlds.tracker.TrackerClient import TrackerGameContext as SuperContext, TrackerCommandProcessor
ClientCommandProcessor = TrackerCommandProcessor
tracker_loaded = True
except ModuleNotFoundError:
from CommonClient import CommonContext as SuperContext
if typing.TYPE_CHECKING:
import kvui
class SortingOrderLoc(IntEnum):
alphabetical = 1
inverted_alphabetical = -1
natural = 2
inverted_natural = -2
default = 2
# Docs must be done after because otherwise __doc__ return none
SortingOrderLoc.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in locations.json."
SortingOrderLoc.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12"
class SortingOrderItem(IntEnum):
alphabetical = 1
inverted_alphabetical = -1
natural = 2
inverted_natural = -2
received = 3
inverted_received = -3
default = 3
SortingOrderItem.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in items.json."
SortingOrderItem.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12"
SortingOrderItem.received.__doc__ = "Sort the item in the order they are received from the server"
class SortingOrderCategories(IntEnum):
alphabetical = 1
inverted_alphabetical = -1
natural = 2
inverted_natural = -2
default = 2
SortingOrderCategories.alphabetical.__doc__ = "Sort alphabetically using the name of the category."
SortingOrderCategories.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12"
@cache
def strip_articles(title: str) -> str:
lower = title.lower()
if lower.startswith("the "):
title = title[4:]
elif lower.startswith("a "):
title = title[2:]
elif lower.startswith("an "):
title = title[3:]
return title
def natural_sort_key(key: str):
# Modified from https://stackoverflow.com/a/11150413
def convert(text):
return int(text) if text.isdigit() else text.lower()
key = strip_articles(key)
return [convert(c) for c in re.split('([0-9]+)', key)]
class ManualClientCommandProcessor(ClientCommandProcessor):
ctx: ManualContext
def _cmd_resync(self) -> bool:
"""Manually trigger a resync."""
self.output("Syncing items.")
self.ctx.syncing = True
return True
@mark_raw
def _cmd_send(self, location_name: str) -> bool:
"""Send a check"""
names = self.ctx.location_names_to_id.keys()
location_name, usable, response = Utils.get_intended_text(
location_name,
names
)
if usable:
location_id = self.ctx.location_names_to_id[location_name]
self.ctx.locations_checked.append(location_id)
self.ctx.syncing = True
return True
else:
self.output(response)
return False
@mark_raw
def _cmd_open_settings(self) -> bool:
"""Open the settings panel."""
if gui_enabled:
self.ctx.ui.open_settings()
return True
else:
self.output("GUI is not enabled.")
return False
class ManualContext(SuperContext):
command_processor = ManualClientCommandProcessor
game = None # this is changed in server_auth below based on user input
items_handling = 0b111 # full remote
tags = {"AP"}
location_table = {}
item_table = {}
region_table = {}
category_table = {}
tracker_reachable_locations: list[str] = []
tracker_glitched_locations: list[str] = []
tracker_reachable_events: list[str] = []
set_deathlink = False
last_death_link = 0
deathlink_out = False
goaled = False
visible_events: dict[str, dict[str, Any]] = {}
search_term = ""
items_sorting = SortingOrderItem.default.name
items_sorting_uses_sortkeys = True
item_categories_sorting = SortingOrderCategories.default.name
item_categories_sorting_uses_sortkeys = True
locations_sorting = SortingOrderLoc.default.name
locations_sorting_uses_sortkeys = True
location_categories_sorting = SortingOrderCategories.default.name
location_categories_sorting_uses_sortkeys = True
block_unreachable_location_press = True
display_glitched_locations = True
allow_glitched_location_press = True
colors = {
'location_default': [219/255, 218/255, 213/255, 1],
'location_in_logic': [2/255, 242/255, 42/255, 1],
'location_in_glitched_logic': [247/255, 255/255, 119/255, 1],
'category_even_default': [0.5, 0.5, 0.5, 0.1],
'category_odd_default': [1.0, 1.0, 1.0, 0.0],
'category_in_logic': [2/255, 82/255, 2/255, 1],
'category_in_glitched_logic': [172/255, 179/255, 81/255, 1],
'deathlink_received': [1, 0, 0, 1],
'deathlink_primed': [1, 1, 1, 1],
'deathlink_sent': [0, 1, 0, 1],
'game_select_button': [200/255, 200/255, 200/255, 1],
'header_background': [15/255, 80/255, 112/255, 1]
}
def __init__(self, server_address, password, game, player_name) -> None:
super(ManualContext, self).__init__(server_address, password)
if tracker_loaded:
super().set_callback(self.on_tracker_updated) # Universal Tracker takes this func and calls it when updateTracker is called
if hasattr(self, "set_events_callback"):
super().set_events_callback(self.on_tracker_events) # Universal Tracker takes this func and calls it when events are calculated
if hasattr(self, "set_glitches_callback"):
super().set_glitches_callback(self.on_glitches_call) # Universal Tracker takes this func and calls it when OOL glitched location are calculated
self.send_index: int = 0
self.syncing = False
self.game = game
self.username = player_name
self.locations_checked: list[int] = []
self.locations_scouted: list[int] = []
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
if tracker_loaded:
await super(SuperContext, self).server_auth(password_requested) # type: ignore
else:
await super(ManualContext, self).server_auth(password_requested)
if "Manual_" not in self.ui.game_bar_text.text:
raise Exception("The Manual client can only be used for Manual games.")
self.game = self.ui.game_bar_text.text
self.goaled = False
world = AutoWorldRegister.world_types.get(self.game)
if not self.location_table and not self.item_table and world is None:
raise Exception(f"Cannot load {self.game}, please add the apworld to custom_worlds/")
data_package = network_data_package["games"].get(self.game, {})
self.update_ids(data_package)
if world is not None and hasattr(world, "victory_names"):
self.victory_names = world.victory_names
self.goal_location = self.get_location_by_name(world.victory_names[0])
else:
self.victory_names = ["__Manual Game Complete__"]
self.goal_location = self.get_location_by_name("__Manual Game Complete__")
await self.get_username()
await self.send_connect()
async def connection_closed(self):
await super(ManualContext, self).connection_closed()
@property
def suggested_game(self) -> str:
if self.game:
return self.game
from .Game import game_name # This will at least give us the name of a manual they've installed
return Utils.persistent_load().get("client", {}).get("last_manual_game", None) or game_name
def get_location_by_name(self, name) -> dict[str, Any]:
location = self.location_table.get(name)
if not location:
# It is absolutely possible to pull categories from the data_package via self.update_game. I have not done this yet.
location = AutoWorldRegister.world_types[self.game].location_name_to_location.get(name, {"name": name}) # type: ignore
return location
def get_location_by_id(self, id) -> dict[str, Any]:
name = self.location_names.lookup_in_game(id)
return self.get_location_by_name(name)
def get_item_by_name(self, name):
item = self.item_table.get(name)
if not item:
item = AutoWorldRegister.world_types[self.game].item_name_to_item.get(name, {"name": name}) # type: ignore
return item
def get_item_by_id(self, id):
name = self.item_names.lookup_in_game(id)
return self.get_item_by_name(name)
def get_category_by_name( self, name:str) -> dict[str, Any]:
category = self.category_table.get(name)
if not category:
category = AutoWorldRegister.world_types[self.game].category_table.get(name, {"name": name}) # type: ignore
return category
def get_sorting_key_by_id(self, key, is_item=True) -> str:
Manual_dict: Dict[str, str] = self.get_item_by_id(key) if is_item else self.get_location_by_id(key)
name: str | None = None
if (is_item and self.items_sorting_uses_sortkeys) or \
(not is_item and self.locations_sorting_uses_sortkeys):
name = Manual_dict.get("sort-key")
name = Manual_dict["name"] if name is None else name
name = strip_articles(name.lower())
return name
def get_category_sorting_key_by_name(self, name, is_item = True) -> str:
Manual_dict: Dict[str, Any] = self.get_category_by_name(name)
key: str | None = None
if (is_item and self.item_categories_sorting_uses_sortkeys) or\
(not is_item and self.location_categories_sorting_uses_sortkeys):
key = Manual_dict.get("sort-key")
key = name if key is None else key
key = strip_articles(key.lower())
return key
def update_ids(self, data_package) -> None:
self.location_names_to_id = data_package['location_name_to_id']
self.item_names_to_id = data_package['item_name_to_id']
def update_data_package(self, data_package: dict):
super().update_data_package(data_package)
for game, game_data in data_package["games"].items():
if game == self.game:
self.update_ids(game_data)
def set_search(self, search_term: str):
self.search_term = search_term
def clear_search(self):
self.search_term = ""
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
await super(ManualContext, self).shutdown()
def on_package(self, cmd: str, args: dict):
super().on_package(cmd, args)
if cmd in {"Connected", "DataPackage"}:
if cmd == "Connected":
Utils.persistent_store("client", "last_manual_game", self.game)
if args.get("slot_data"):
goal = args["slot_data"].get("goal")
if goal and goal < len(self.victory_names):
self.goal_location = self.get_location_by_name(self.victory_names[goal])
if args['slot_data'].get('death_link'):
self.ui.enable_death_link()
self.set_deathlink = True
self.last_death_link = 0
self.visible_events = args['slot_data'].get('visible_events', {})
logger.info(f"Slot data: {args['slot_data']}")
self.ui.build_tracker_and_locations_table()
self.ui.request_update_tracker_and_locations_table(update_highlights=True)
elif cmd in {"ReceivedItems"}:
self.ui.request_update_tracker_and_locations_table(update_highlights=True)
elif cmd in {"RoomUpdate"}:
self.ui.request_update_tracker_and_locations_table(update_highlights=False)
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
super().on_deathlink(data)
self.ui.death_link_button.text = f"Death Link: {data['source']}"
self.ui.death_link_button.background_color = self.colors['deathlink_received']
def on_tracker_updated(self, reachable_locations: list[str]):
self.tracker_reachable_locations = reachable_locations
self.ui.request_update_tracker_and_locations_table(update_highlights=True)
def on_tracker_events(self, events: list[str]):
self.tracker_reachable_events = events
if events:
self.ui.request_update_tracker_and_locations_table(update_highlights=True)
def on_glitches_call(self, glitched_locations: list[str]):
self.tracker_glitched_locations = glitched_locations
if glitched_locations:
self.ui.request_update_tracker_and_locations_table(update_highlights=True)
def is_event_visible(self, event_name, category_name):
if event_name not in self.visible_events:
return False
if category_name == "(No Category)" and len(self.visible_events[event_name]) == 0:
return True
return category_name in self.visible_events[event_name]
def handle_connection_loss(self, msg: str) -> None:
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
exc_info = sys.exc_info()
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
tracker_error = False
e = exc_info[2]
formatted_tb = ''.join(traceback.format_tb(e))
while e:
if '/tracker/' in e.tb_frame.f_code.co_filename:
tracker_error = True
break
e = e.tb_next
if tracker_error:
self._messagebox_connection_loss = self.gui_error(
"A Universal Tracker error has occurred. Please ensure that your version of UT matches your version of Archipelago.",
formatted_tb)
else:
self._messagebox_connection_loss = self.gui_error(msg, formatted_tb)
def run_gui(self):
"""Import kivy UI system from make_gui() and start running it as self.ui_task."""
if hasattr(SuperContext, "make_gui"):
# Call the real one if it exists
return super().run_gui()
# This is a copy of 0.5.1's run_gui, because backporting is easier than the alternative.
# This entire function can be removed once 0.5.1 is the old enough.
ui_class = self.make_gui()
self.ui = ui_class(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def make_gui(self) -> typing.Type["kvui.GameManager"]:
if hasattr(SuperContext, "make_gui"):
ui = super().make_gui() # before the kivy imports so kvui gets loaded first
else:
from kvui import GameManager
ui = GameManager
from kivy.core.window import Window
from kivy.lang import Builder
from kivy.metrics import dp
from kivy.properties import ColorProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.dropdown import DropDown
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.layout import Layout
from kivy.uix.scrollview import ScrollView
from kivy.uix.settings import Settings
from kivy.uix.spinner import Spinner, SpinnerOption
from kivy.uix.textinput import TextInput
from kivy.uix.treeview import TreeView, TreeViewLabel, TreeViewNode
from kivy.config import ConfigParser
class ManualTabLayout(BoxLayout):
pass
class ManualControlsLayout(BoxLayout):
pass
class TrackerAndLocationsLayout(GridLayout):
pass
class TrackerLayoutScrollable(ScrollView):
pass
class LocationsLayoutScrollable(ScrollView):
pass
class TreeViewButton(Button, TreeViewNode):
victory: bool = False
id: int|None = None
location_name: str = ""
class ItemLabel(Label):
item_id: int|None = None
item_count: int = 1
item_name: str = ""
class TreeViewScrollView(ScrollView, TreeViewNode):
pass
class GameSelectOption(SpinnerOption):
background_color = self.colors['game_select_button']
class GameSelectDropDown(DropDown):
# If someone can figure out how to give this a solid background, I'd be very happy.
pass
Builder.load_string(
"""
<ManualControlsStyledLayout>:
canvas:
Color:
rgba: root.background_color
Rectangle:
pos: self.pos
size: self.size
""")
class ManualControlsStyledLayout(BoxLayout):
background_color = ColorProperty()
class ManualManager(ui):
base_title = "Archipelago Manual Client"
listed_items = {"(No Category)": []}
item_categories = ["(No Category)"]
listed_locations = {"(No Category)": []}
location_categories = ["(No Category)"]
active_item_accordion = 0
active_location_accordion = 0
update_requested_time: Optional[float] = None
update_requested_highlights: bool = False
mouse_pos: tuple
ctx: ManualContext
def __init__(self, ctx):
super().__init__(ctx)
def build(self) -> Layout:
super().build()
self.ctx.items_sorting = self.config.get('manual', 'items_sorting_order')
self.ctx.items_sorting_uses_sortkeys = self.config.get('manual', 'items_sorting_uses_sortkeys')
self.ctx.locations_sorting = self.config.get('manual', 'locations_sorting_order')
self.ctx.locations_sorting_uses_sortkeys = self.config.get('manual', 'locations_sorting_uses_sortkeys')
self.ctx.item_categories_sorting = self.config.get('manual', 'item_categories_sorting_order')
self.ctx.item_categories_sorting_uses_sortkeys = self.config.get('manual', 'item_categories_sorting_uses_sortkeys')
self.ctx.location_categories_sorting = self.config.get('manual', 'location_categories_sorting_order')
self.ctx.location_categories_sorting_uses_sortkeys = self.config.get('manual', 'location_categories_sorting_uses_sortkeys')
self.ctx.block_unreachable_location_press = True if self.config.get('universal-tracker', 'block_unreachable_location_press') == "Yes" else False
self.ctx.display_glitched_locations = True if self.config.get('universal-tracker', 'display_glitched_locations') == "Yes" else False
self.ctx.allow_glitched_location_press = True if self.config.get('universal-tracker', 'allow_glitched_location_press') == "Yes" else False
self.manual_game_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
game_bar_label = Label(text="Manual Game ID", size=(dp(150), dp(30)), size_hint_y=None, size_hint_x=None)
manuals = [w for w in AutoWorldRegister.world_types.keys() if "Manual_" in w]
manuals.sort() # Sort by alphabetical order, not load order
self.manual_game_layout.add_widget(game_bar_label)
self.game_bar_text = Spinner(text=self.ctx.suggested_game, size_hint_y=None, height=dp(30), sync_height=True,
values=manuals, option_cls=GameSelectOption, dropdown_cls=GameSelectDropDown)
self.manual_game_layout.add_widget(self.game_bar_text)
self.grid.add_widget(self.manual_game_layout, 3)
panel = self.add_client_tab("Manual", ManualTabLayout(orientation="vertical"))
self.controls_panel = ManualControlsLayout(orientation="horizontal", size_hint_y=None, height=dp(40))
self.tracker_and_locations_panel = TrackerAndLocationsLayout(cols = 2)
panel.content.add_widget(self.controls_panel)
panel.content.add_widget(self.tracker_and_locations_panel)
self.build_tracker_and_locations_table()
return self.container
def get_application_config(self, defaultpath: str = "") -> str:
return Utils.user_path("manual_client.ini")
def build_config(self, config: ConfigParser):
super().build_config(config)
config.setdefaults("manual", {
"items_sorting_order": SortingOrderItem.default.name,
"items_sorting_uses_sortkeys": "Yes",
"locations_sorting_order": SortingOrderLoc.default.name,
"locations_sorting_uses_sortkeys": "Yes",
"item_categories_sorting_order": SortingOrderCategories.default.name,
"item_categories_sorting_uses_sortkeys": "Yes",
"location_categories_sorting_order": SortingOrderCategories.default.name,
"location_categories_sorting_uses_sortkeys": "Yes"
})
config.setdefaults("universal-tracker", {
"block_unreachable_location_press": "Yes",
"display_glitched_locations": "Yes",
"allow_glitched_location_press": "Yes"
})
def build_settings(self, settings: Settings):
super().build_settings(settings)
json_data = [
{
"type": "title",
"title": "Manual Client"
},
{
"type": "options",
"title": "Items Sorting Order",
"section": "manual",
"key": "items_sorting_order",
"options": list(SortingOrderItem._member_names_),
"desc": '\n'.join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderItem if i.__doc__ is not None])
},
{
"type": "bool",
"title": "Items Sorting uses sortkeys",
"desc": "Should Item sorting respect sort-keys",
"section": "manual",
"key": "items_sorting_uses_sortkeys",
"values": ["No", "Yes"]
},
{
"type": "options",
"title": "Locations Sorting Order",
"section": "manual",
"key": "locations_sorting_order",
"options": list(SortingOrderLoc._member_names_),
"desc": "\n".join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderLoc if i.__doc__ is not None])
},
{
"type": "bool",
"title": "Locations Sorting uses sortkeys",
"desc": "Should Location sorting respect sort-keys",
"section": "manual",
"key": "locations_sorting_uses_sortkeys",
"values": ["No", "Yes"]
},
{
"type": "options",
"title": "Item Categories Sorting Order",
"section": "manual",
"key": "item_categories_sorting_order",
"options": list(SortingOrderCategories._member_names_),
"desc": '\n'.join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderCategories if i.__doc__ is not None])
},
{
"type": "bool",
"title": "Item Categories Sorting uses sortkeys",
"desc": "Should Item Category sorting respect sort-keys",
"section": "manual",
"key": "item_categories_sorting_uses_sortkeys",
"values": ["No", "Yes"]
},
{
"type": "options",
"title": "Location Categories Sorting Order",
"section": "manual",
"key": "location_categories_sorting_order",
"options": list(SortingOrderCategories._member_names_),
"desc": "Same Options as Items Category Sorting Order."
},
{
"type": "bool",
"title": "Location Categories Sorting uses sortkeys",
"desc": "Should Location sorting respect sort-keys",
"section": "manual",
"key": "location_categories_sorting_uses_sortkeys",
"values": ["No", "Yes"]
},
]
if tracker_loaded:
json_data.extend([
{
"type": "title",
"title": "Universal Tracker Compatibility"
},
{
"type": "bool",
"title": "Stop accidental button press",
"section": "universal-tracker",
"key": "block_unreachable_location_press",
"desc": "Should only reachable location be able to be pressed",
"values": ["No", "Yes"]
},
{
"type": "bool",
"title": "See Glitched Logic",
"section": "universal-tracker",
"key": "display_glitched_locations",
"desc": "Should locations marked as accessible OOL be shown in Yellow",
"values": ["No", "Yes"]
},
{
"type": "bool",
"title": "Count glitched location as reachable for the press protection",
"section": "universal-tracker",
"key": "allow_glitched_location_press",
"desc": "Should locations marked as accessible OOL not be protected from being pressed",
"values": ["No", "Yes"]
},
])
settings.add_json_panel("Manual Client Settings", self.config, data=json.dumps(json_data))
def on_config_change(self, config, section, key, value):
super().on_config_change(config, section, key, value)
if section == "manual":
if key == "items_sorting_order":
if value in SortingOrderItem._member_names_:
self.ctx.items_sorting = value
self.request_update_tracker_and_locations_table()
elif key == "items_sorting_uses_sortkeys":
self.ctx.items_sorting_uses_sortkeys = True if value == "Yes" else False
self.request_update_tracker_and_locations_table()
elif key == "locations_sorting_order":
if value in SortingOrderLoc._member_names_:
self.ctx.locations_sorting = value
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
elif key == "locations_sorting_uses_sortkeys":
self.ctx.locations_sorting_uses_sortkeys = True if value == "Yes" else False
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
elif key == "location_categories_sorting_order":
if value in SortingOrderCategories._member_names_:
self.ctx.location_categories_sorting = value
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
elif key == "location_categories_sorting_uses_sortkeys":
self.ctx.location_categories_sorting_uses_sortkeys = True if value == "Yes" else False
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
elif key == "item_categories_sorting_order":
if value in SortingOrderCategories._member_names_:
self.ctx.item_categories_sorting = value
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
elif key == "item_categories_sorting_uses_sortkeys":
self.ctx.item_categories_sorting_uses_sortkeys = True if value == "Yes" else False
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
elif section == "universal-tracker":
if key == "block_unreachable_location_press":
self.ctx.block_unreachable_location_press = True if value == "Yes" else False
elif key == "display_glitched_locations":
self.ctx.display_glitched_locations = True if value == "Yes" else False
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
elif key == "allow_glitched_location_press":
self.ctx.allow_glitched_location_press = True if value == "Yes" else False
def clear_lists(self):
self.listed_items = {"(No Category)": []}
self.item_categories = ["(No Category)"]
self.listed_locations: Dict[str, List[int]] = {"(No Category)": [], "(Hinted)": []}
self.location_categories = ["(No Category)", "(Hinted)"]
def set_active_item_accordion(self, instance):
index = 0
for widget in self.children:
if widget == instance:
self.active_item_accordion = index
return
index += 1
def set_active_location_accordion(self, instance):
index = 0
for widget in self.children:
if widget == instance:
self.active_item_accordion = index
return
index += 1
def enable_death_link(self):
if not hasattr(self, "death_link_button"):
self.death_link_button = Button(text="Death Link: Primed",
size_hint_x=None, width=150)
self.connect_layout.add_widget(self.death_link_button)
self.death_link_button.bind(on_release=self.send_death_link)
def send_death_link(self, *args):
if self.ctx.last_death_link:
self.ctx.last_death_link = 0
self.death_link_button.text = "Death Link: Primed"
self.death_link_button.background_color = self.ctx.colors['deathlink_primed']
else:
self.ctx.deathlink_out = True
self.death_link_button.text = "Death Link: Sent"
self.death_link_button.background_color = self.ctx.colors['deathlink_sent']
def update_hints(self):
super().update_hints()
rebuild = False
for hint in self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", []):
if hint["finding_player"] == self.ctx.slot:
if hint["location"] in self.ctx.missing_locations:
location = self.ctx.get_location_by_id(hint["location"])
location["category"] = location.get("category", [])
if "(Hinted)" not in location["category"]:
location["category"].append("(Hinted)")
rebuild = True
json_str = [
{"type": "player_id", "text": hint["receiving_player"]},
{"type": "text", "text": "'s "},
{"type": "item_id",
"text": hint["item"],
"flags": hint["item_flags"],
"player": hint["receiving_player"],
},
]
location['hint_text'] = self.json_to_kivy_parser(json_str)
if rebuild:
self.build_tracker_and_locations_table()
self.request_update_tracker_and_locations_table()
def update_search_from_input(self, instance, text: str):
self.ctx.set_search(text)
self.request_update_tracker_and_locations_table() # if we want search to be "snappier", we can just make this update
def clear_search_input(self):
self.search_textbox.text = ""
self.ctx.clear_search()
self.request_update_tracker_and_locations_table() # if we want search to be "snappier", we can just make this update
def window_mouseover(self, window, pos):
self.set_mouse_pos(window, pos)
def set_mouse_pos(self, window, pos):
self.mouse_pos = pos
def are_top_controls_at_mouse_pos(self) -> bool:
# check server connect section and controls
if self.connect_layout.collide_point(*self.mouse_pos):
return True
# check game id section and dropdown
if self.manual_game_layout.collide_point(*self.mouse_pos):
return True
# check tabbed navigation
if self.tabs.collide_point(*self.mouse_pos):
return True
return False
def get_top_obj_at_mouse_pos(self) -> Any:
for child in self.connect_layout.children:
if child.collide_point(*self.mouse_pos):
return child
for child in self.manual_game_layout.children:
if child.collide_point(*self.mouse_pos):
return child
for child in self.tabs.children:
if child.collide_point(*self.mouse_pos):
return child
return None
def build_tracker_and_locations_table(self):
Window.bind(mouse_pos=self.window_mouseover)
self.controls_panel.clear_widgets()
self.tracker_and_locations_panel.clear_widgets()
if not self.ctx.server or not self.ctx.auth:
self.tracker_and_locations_panel.add_widget(
Label(text="Waiting for connection...", size_hint_y=None, height=50, outline_width=1))
return
self.clear_lists()
# build tab-specific controls above the two tracker columns
controls_styled_layout = ManualControlsStyledLayout(orientation="horizontal", size_hint_y=None, height=dp(40), padding=dp(5), background_color=self.ctx.colors["header_background"])
search_layout = BoxLayout(orientation="horizontal", size_hint=(None, None), width=dp(320), height=dp(30), spacing=dp(2))
search_label = Label(text="Search:", size_hint=(None, None), width=dp(55), height=dp(30), bold=True)
self.search_textbox = TextInput(size_hint=(None, None), width=dp(200), height=dp(30), multiline=False, write_tab=False)
self.search_textbox.bind(text = self.update_search_from_input)
search_button = Button(size_hint=(None, None), width=dp(50), height=dp(30), text="Clear")
search_button.bind(on_release=lambda *args: self.clear_search_input())
controls_styled_layout.add_widget(search_layout)
search_layout.add_widget(search_label)
search_layout.add_widget(self.search_textbox)
search_layout.add_widget(search_button)
self.controls_panel.add_widget(controls_styled_layout)
# seed all category names to start
for item in self.ctx.item_table.values() or AutoWorldRegister.world_types[self.ctx.game].item_name_to_item.values():
if "category" in item and len(item["category"]) > 0:
for category in item["category"]:
category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {})
if "hidden" in category_settings and category_settings["hidden"]:
continue
if category not in self.item_categories:
self.item_categories.append(category)
if category not in self.listed_items:
self.listed_items[category] = []
for event, categories in self.ctx.visible_events.items():
for category in categories:
category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {})
if "hidden" in category_settings and category_settings["hidden"]:
continue
if category not in self.item_categories:
self.item_categories.append(category)
if category not in self.listed_items:
self.listed_items[category] = []
# Items are not received on connect, so don't bother attempting to work with received items here
if not self.ctx.location_table and not hasattr(AutoWorldRegister.world_types[self.ctx.game], 'location_name_to_location'):
raise Exception("The apworld for %s is too outdated for this client. Please update it." % (self.ctx.game))
scoutable_locations = set()
hinted_locations = {}
for location_id in self.ctx.missing_locations:
# holy nesting, wow
location_name = self.ctx.location_names.lookup_in_game(location_id)
location = self.ctx.get_location_by_name(location_name)
if not location:
continue
if location.get("scoutable", False):
scoutable_locations.add(location_id)
if location.get("hint_text", False):
hinted_locations[location_id] = location.get("hint_text", "")
if "category" in location and len(location["category"]) > 0:
for category in location["category"]:
category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {})
if "hidden" in category_settings and category_settings["hidden"]:
continue
if category not in self.location_categories:
self.location_categories.append(category)
if category not in self.listed_locations:
self.listed_locations[category] = []
self.listed_locations[category].append(location_id)
else: # leave it in the generic category
self.listed_locations["(No Category)"].append(location_id)
victory_location = self.ctx.goal_location
victory_categories = set(victory_location.get("category", []))
for category in victory_categories:
if category not in self.location_categories:
self.location_categories.append(category)
if category not in self.listed_locations:
self.listed_locations[category] = []
if not victory_categories:
victory_categories.add("(No Category)")
loc_sorting = SortingOrderLoc[self.ctx.locations_sorting]
def get_location_key(key: int) -> str:
return self.ctx.get_sorting_key_by_id(key, is_item=False)
if abs(loc_sorting) == SortingOrderLoc.alphabetical:
for category in self.listed_locations:
self.listed_locations[category].sort(key=get_location_key, reverse=loc_sorting < 0)
elif abs(loc_sorting) == SortingOrderLoc.natural:
def alphanum_key(key: int) -> list[str|int]:
return natural_sort_key(get_location_key(key))
for category in self.listed_locations:
self.listed_locations[category].sort(key=alphanum_key, reverse=loc_sorting < 0)
items_length = len(self.ctx.items_received)
tracker_panel_scrollable = TrackerLayoutScrollable(do_scroll=(False, True), bar_width=10)
tracker_panel = TreeView(root_options=dict(text="Items Received (%d)" % (items_length)), size_hint_y=None)
tracker_panel.bind(minimum_height=tracker_panel.setter('height'))
def get_item_category_key(key: str) -> str:
return self.ctx.get_category_sorting_key_by_name(key)
def item_category_normal_sort_key(key: str):
result = [get_item_category_key(key)]
return [0 if key.lstrip().startswith("(") else 1] + result
def item_category_nat_sort_key(key: str):
result = natural_sort_key(get_item_category_key(key))
return [0 if key.lstrip().startswith("(") else 1] + result
# Sorting items categories
item_cat_sorting = SortingOrderCategories[self.ctx.item_categories_sorting]
if abs(item_cat_sorting) == SortingOrderCategories.natural:
self.listed_items = {key: self.listed_items[key] for key in
sorted(self.listed_items.keys(),
key=item_category_nat_sort_key,
reverse=item_cat_sorting < 0)}
else:
self.listed_items = {key: self.listed_items[key] for key in
sorted(self.listed_items.keys(),
key=item_category_normal_sort_key,
reverse=item_cat_sorting < 0)}
# Since items_received is not available on connect, don't bother building item labels here
for item_category in self.listed_items.keys():
category_tree = tracker_panel.add_node(
TreeViewLabel(text = "%s (%s)" % (item_category, len(self.listed_items[item_category])))
)
category_scroll = tracker_panel.add_node(TreeViewScrollView(size_hint=(1, None), size=(Window.width / 2, 250)), category_tree)
category_layout = GridLayout(cols=1, size_hint_y=None)
category_layout.bind(minimum_height = category_layout.setter('height'))
category_scroll.add_widget(category_layout)
locations_length = len(self.ctx.missing_locations)
locations_panel_scrollable = LocationsLayoutScrollable(do_scroll=(False, True), bar_width=10)
locations_panel = TreeView(root_options=dict(text="Remaining Locations (%d)" % (locations_length + 1)), size_hint_y=None)
locations_panel.bind(minimum_height=locations_panel.setter('height'))
# This seems like a redundant copy of the same check above?
if not self.ctx.location_table and not hasattr(AutoWorldRegister.world_types[self.ctx.game], 'location_name_to_location'):
raise Exception("The apworld for %s is too outdated for this client. Please update it." % (self.ctx.game))
# Sorting location categories
loc_cat_sorting = SortingOrderCategories[self.ctx.location_categories_sorting]
def get_location_category_key(key: str) -> str:
return self.ctx.get_category_sorting_key_by_name(key, is_item=False)
def loc_category_normal_sort_key(key: str):
result = [get_location_category_key(key)]
return [0 if key.lstrip().startswith("(") else 1] + result
def loc_category_nat_sort_key(key: str):
result = natural_sort_key(get_location_category_key(key))
return [0 if key.lstrip().startswith("(") else 1] + result
if abs(loc_cat_sorting) == SortingOrderCategories.natural:
self.listed_locations = {key: self.listed_locations[key] for key in
sorted(self.listed_locations.keys(),
key=loc_category_nat_sort_key,
reverse=loc_cat_sorting < 0)}
else:
self.listed_locations = {key: self.listed_locations[key] for key in
sorted(self.listed_locations.keys(),
key=loc_category_normal_sort_key,
reverse=loc_cat_sorting < 0)}
for location_category in self.listed_locations.keys():
locations_in_category = len(self.listed_locations[location_category])
if (location_category in victory_categories) or \
(not victory_categories and location_category == "(No Category)"):
locations_in_category += 1
category_tree = locations_panel.add_node(
TreeViewLabel(text = "%s (%s)" % (location_category, locations_in_category))
)
category_scroll = locations_panel.add_node(TreeViewScrollView(size_hint=(1, None), size=(Window.width / 2, 250)), category_tree)
category_layout = GridLayout(cols=2, size_hint_y=None)
category_layout.bind(minimum_height = category_layout.setter('height'))
category_scroll.add_widget(category_layout)
for location_id in self.listed_locations[location_category]:
has_hint = location_id in hinted_locations or location_id in scoutable_locations
text = f"{self.ctx.location_names.lookup_in_game(location_id)}"
location_button = TreeViewButton(text=text, size_hint=(.75 if has_hint else 1, None), height=30)
location_button.bind(on_release=lambda *args, loc_id=location_id: self.location_button_callback(loc_id, *args))
location_button.id = location_id
location_button.location_name = self.ctx.location_names.lookup_in_game(location_id)
category_layout.add_widget(location_button)
if location_id in hinted_locations:
category_layout.add_widget(Label(text=hinted_locations[location_id], size_hint=(.25, None), height=30, font_size=10, markup=True))
elif location_id in scoutable_locations:
location_scout = TreeViewButton(text="Scout", size_hint=(.25, None), height=30, font_size=10)
location_scout.bind(on_release=lambda *args, loc_id=location_id: self.location_scout_callback(loc_id, *args))
location_scout.id = location_id
category_layout.add_widget(location_scout)
else:
# Add invisible spacer to maintain 2-column grid structure
category_layout.add_widget(Label(text="", size_hint=(None, None), height=0, width=0, opacity=0))
# if this is the category that Victory is in, display the Victory button
# if ("category" in victory_location_data and location_category in victory_location_data["category"]) or \
# ("category" not in victory_location_data and location_category == "(No Category)"):
if location_category in victory_categories:
# Add the Victory location to be marked at any point, which is why locations length has 1 added to it above
victory_text: str = "VICTORY! (seed finished)" if victory_location["name"] == "__Manual Game Complete__" else "GOAL: " + victory_location["name"]
location_button = TreeViewButton(text=victory_text, size_hint=(1, None), height=dp(30), width=dp(400))
location_button.victory = True
location_button.location_name = victory_location["name"]
location_button.bind(on_release=self.victory_button_callback)
category_layout.add_widget(location_button)
tracker_panel_scrollable.add_widget(tracker_panel)
locations_panel_scrollable.add_widget(locations_panel)
self.tracker_and_locations_panel.add_widget(tracker_panel_scrollable)
self.tracker_and_locations_panel.add_widget(locations_panel_scrollable)
def check_for_requested_update(self):
current_time = time.time()
# wait 0.25 seconds before executing update, in case there are multiple update requests coming in
if self.update_requested_time and current_time - self.update_requested_time >= 0.25:
self.update_requested_time = None
self.update_tracker_and_locations_table(self.update_requested_highlights)
self.update_requested_highlights = False
def request_update_tracker_and_locations_table(self, update_highlights=False):
self.update_requested_time = time.time()
self.update_requested_highlights = update_highlights or self.update_requested_highlights # if any of the requests wanted highlights, do highlight
def update_tracker_and_locations_table(self, update_highlights=False):
items_length = len(self.ctx.items_received)
locations_length = len(self.ctx.missing_locations)
if self.ctx.search_term:
items_length = len([
i for i in self.ctx.items_received
if self.ctx.search_term.lower() in self.ctx.item_names.lookup_in_game(i.item).lower()
])
locations_length = len([
l for l in self.ctx.missing_locations
if self.ctx.search_term.lower() in self.ctx.location_names.lookup_in_game(l).lower()
])
for _, child in enumerate(self.tracker_and_locations_panel.children):
#
# Structure of items:
# TrackerLayoutScrollable -> TreeView -> TreeViewLabel, TreeViewScrollView -> GridLayout -> Label
# item tracker -> category -> category label, category scroll -> label col -> item
#
if type(child) is TrackerLayoutScrollable:
treeview = child.children[0] # TreeView
treeview_nodes = treeview.iterate_all_nodes()
items_received_label = next(treeview_nodes) # always the first node
items_received_label.text = "Items Received (%s)" % (items_length)
# loop for each category in listed items and get the label + scrollview
for x in range(0, len(self.item_categories)):
category_label = next(treeview_nodes) # TreeViewLabel for category
category_scrollview = next(treeview_nodes) # TreeViewScrollView for housing category's grid layout
old_category_text = category_label.text
if type(category_label) is TreeViewLabel and type(category_scrollview) is TreeViewScrollView:
category_grid = category_scrollview.children[0] # GridLayout
category_name = re.sub(r"\s\(\d+\)$", "", category_label.text)
category_count = 0
category_unique_name_count = 0
existing_item_labels = []
bold_item_labels = []
# for items that were already listed, determine if the qty changed. if it did, add them to the list to be bolded
for item in category_grid.children:
if type(item) is ItemLabel:
# Get the item name from the item Label, minus quantity, then do a lookup for count
old_count = item.item_count
item_name = item.item_name
item_id = item.item_id
if item_id:
item_count = len(list(i for i in self.ctx.items_received if i.item == item_id))
else:
item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == item_name))
# if the player is searching for text and the item name doesn't contain it, skip it
if self.ctx.search_term and not self.ctx.search_term.lower() in item_name.lower():
item.width = 0
item.height = 0
item.opacity = 0
else:
item.width = dp(400)
item.height = dp(30)
item.opacity = 1
if item_count > 0:
category_count += item_count
category_unique_name_count += 1
# Update the label quantity
if update_highlights and (old_count != item_count):
bold_item_labels.append(item_name)
item.item_count = item_count
item.text="%s (%s)" % (item_name, item_count)
existing_item_labels.append(item_name)
# instead of reusing existing item listings, clear it all out and re-draw with the sorted list
category_grid.clear_widgets()
self.listed_items[category_name].clear()
category_count = 0
category_unique_name_count = 0
# Label (for all item listings)
item_sorting = SortingOrderItem[self.ctx.items_sorting]
sorted_items_received = [i.item for i in self.ctx.items_received]
def get_item_key(key: int) -> str:
return self.ctx.get_sorting_key_by_id(key, is_item=True)
if abs(item_sorting) == SortingOrderItem.alphabetical:
sorted_items_received = sorted(sorted_items_received,
key=get_item_key,
reverse=item_sorting < 0)
elif abs(item_sorting) == SortingOrderItem.natural:
def alphanum_key(key: int) -> list[str|int]:
return natural_sort_key(get_item_key(key))
sorted_items_received = sorted(sorted_items_received,
key=alphanum_key,
reverse=item_sorting < 0)
elif abs(item_sorting) == SortingOrderItem.received:
if item_sorting < 0:
sorted_items_received.reverse()
for network_item in sorted_items_received:
item_name = self.ctx.item_names.lookup_in_game(network_item)
item_data = self.ctx.get_item_by_name(item_name)
# if the player is searching for text and the item name doesn't contain it, skip it
if self.ctx.search_term and not self.ctx.search_term.lower() in item_name.lower():
continue
if "category" not in item_data or not item_data["category"]:
item_data["category"] = ["(No Category)"]
if category_name in item_data["category"] and network_item not in self.listed_items[category_name]:
item_count = len(list(i for i in self.ctx.items_received if i.item == network_item))
item_text = ItemLabel(text="%s (%s)" % (item_name, item_count),
size_hint=(None, None), height=dp(30), width=dp(400), bold=True)
item_text.item_name = item_name
item_text.item_count = item_count
item_text.item_id = network_item
# if the item was previously listed and was bold, or if it wasn't previously listed at all, make it bold
item_text.bold = (update_highlights and (item_name in bold_item_labels or item_name not in existing_item_labels))
category_grid.add_widget(item_text)
self.listed_items[category_name].append(network_item)
category_count += item_count
category_unique_name_count += 1
for event in sorted(self.ctx.tracker_reachable_events):
if self.ctx.is_event_visible(event, category_name) and event not in self.listed_items[category_name]:
item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == event))
item_text = ItemLabel(text="%s (%s)" % (event, item_count),
size_hint=(None, None), height=dp(30), width=dp(400), bold=True)
item_text.item_name = event
item_text.item_count = item_count
category_grid.add_widget(item_text)
self.listed_items[category_name].append(event)
category_count += item_count
category_unique_name_count += 1
scrollview_height = 30 * category_unique_name_count
if scrollview_height > 250:
scrollview_height = 250
if scrollview_height < 10:
scrollview_height = 50
category_name = re.sub(r"\s\(\d+\)$", "", category_label.text)
category_label.text = "%s (%s)" % (category_name, category_count)
if update_highlights:
category_label.bold = True if old_category_text != category_label.text else False
category_scrollview.size=(Window.width / 2, scrollview_height)
#
# Structure of locations:
# LocationsLayoutScrollable -> TreeView -> TreeViewLabel, TreeViewScrollView -> GridLayout -> Button
# location tracker -> category -> category label, category scroll -> label col -> location
#
if type(child) is LocationsLayoutScrollable:
treeview = child.children[0] # TreeView
treeview_nodes = treeview.iterate_all_nodes()
locations_remaining_label = next(treeview_nodes) # always the first node
locations_remaining_label.text = "Remaining Locations (%d)" % (locations_length)
# loop for each category in listed items and get the label + scrollview
for x in range(0, len(self.location_categories)):
category_label = next(treeview_nodes) # TreeViewLabel for category
category_scrollview = next(treeview_nodes) # TreeViewScrollView for housing category's grid layout
if type(category_label) is TreeViewLabel and type(category_scrollview) is TreeViewScrollView:
category_grid = category_scrollview.children[0] # GridLayout
category_name = re.sub(r"\s\(\d+\/?(\d+)?\)$", "", category_label.text)
category_count = 0
reachable_count = 0
reachable_glitch_count = 0
buttons_to_remove = []
# since victory is handled more briefly below, need to pull show/hide into functions here to reuse
def show_button_during_search(btn: TreeViewButton):
btn.width = dp(400)
btn.height = dp(30)
btn.opacity = 1
btn.disabled = False
def hide_button_during_search(btn: TreeViewButton):
btn.width = 0
btn.height = 0
btn.opacity = 0
btn.disabled = True
# Label (for existing item listings)
for location_button in category_grid.children:
if type(location_button) is TreeViewButton:
# Skip scout buttons
if location_button.text == "Scout":
continue
# should only be true for the victory location button, which has different text
if location_button.victory:
# if the player is searching for text and the location name doesn't contain it, hide and disable it
if self.ctx.search_term and not self.ctx.search_term.lower() in location_button.text.lower():
hide_button_during_search(location_button)
else:
show_button_during_search(location_button)
category_count += 1
if location_button.victory and "__Victory__" in self.ctx.tracker_reachable_events:
location_button.background_color = self.ctx.colors['location_in_logic']
reachable_count += 1
elif self.ctx.display_glitched_locations and location_button.victory and "__Victory__" in self.ctx.tracker_glitched_locations:
location_button.background_color = self.ctx.colors['location_in_glitched_logic']
reachable_glitch_count += 1
continue
if location_button.id and location_button.id not in self.ctx.missing_locations:
logger.info("location button being removed: " + location_button.text)
buttons_to_remove.append(location_button)
continue
was_reachable = False
was_reachable_glitched = False
if location_button.location_name in self.ctx.tracker_reachable_locations:
location_button.background_color = self.ctx.colors['location_in_logic']
was_reachable = True
elif self.ctx.display_glitched_locations and location_button.text in self.ctx.tracker_glitched_locations:
location_button.background_color = self.ctx.colors['location_in_glitched_logic']
was_reachable_glitched = True
else:
location_button.background_color = self.ctx.colors['location_default']
# if the player is searching for text and the location name doesn't contain it, hide and disable it
if self.ctx.search_term and not self.ctx.search_term.lower() in location_button.text.lower():
hide_button_during_search(location_button)
else:
show_button_during_search(location_button)
if was_reachable:
reachable_count += 1
elif was_reachable_glitched:
reachable_glitch_count += 1
category_count += 1
for location_button in buttons_to_remove:
parent = location_button.parent
button_index = parent.children.index(location_button)
# Grid cols=2: pairs are at adjacent indices (even with odd, odd with even)
pair_index = button_index - 1 if button_index % 2 == 1 else button_index + 1
if 0 <= pair_index < len(parent.children):
parent.remove_widget(parent.children[pair_index])
parent.remove_widget(location_button)
scrollview_height = 30 * category_count
if scrollview_height > 250:
scrollview_height = 250
if scrollview_height < 10:
scrollview_height = 50
count_text: str = str(category_count)
if tracker_loaded:
if self.ctx.display_glitched_locations and reachable_glitch_count:
count_text = f"{reachable_count}/{category_count} ({reachable_glitch_count})"
else:
count_text = f"{reachable_count}/{category_count}"
category_name = re.sub(r"\s\(\d+\/?([\d\s]+)?(\(\d+\))?\)$", "", category_label.text)
category_label.text = "%s (%s)" % (category_name, count_text)
if reachable_count > 0:
# treeviewlabels don't have background color. because #justkivythings.
category_label.even_color = self.ctx.colors['category_in_logic']
category_label.odd_color = self.ctx.colors['category_in_logic']
category_label.odd_color[3] = 0.9
elif self.ctx.display_glitched_locations and reachable_glitch_count > 0:
category_label.even_color = self.ctx.colors['category_in_glitched_logic']
category_label.odd_color = self.ctx.colors['category_in_glitched_logic']
category_label.odd_color[3] = 0.9
else:
category_label.even_color = self.ctx.colors['category_even_default']
category_label.odd_color = self.ctx.colors['category_odd_default']
category_scrollview.size=(Window.width / 2, scrollview_height)
def location_button_callback(self, location_id: int, button: TreeViewButton):
if button.location_name not in self.ctx.location_names_to_id:
raise Exception("Locations were not loaded correctly. Please reconnect your client.")
# if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath
if self.are_top_controls_at_mouse_pos():
# if there's an obj in the top controls/tab at the current mouse position, click it instead
if hovered_obj := self.get_top_obj_at_mouse_pos():
if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc.
hovered_obj.trigger_action(duration=0)
elif hasattr(hovered_obj, 'focus'): # text inputs
hovered_obj.focus = True
return
if location_id:
if tracker_loaded and self.ctx.block_unreachable_location_press:
if button.location_name in self.ctx.tracker_reachable_locations:
pass
elif self.ctx.display_glitched_locations and self.ctx.allow_glitched_location_press \
and button.location_name in self.ctx.tracker_glitched_locations:
pass
else:
logger.debug(f"button for location '{button.location_name}' was pressed while unreachable")
return
self.ctx.locations_checked.append(location_id)
self.ctx.syncing = True
# Remove both the location button and its adjacent scout button/spacer from 2-column grid
parent = button.parent
button_index = parent.children.index(button)
# Grid cols=2: pairs are at adjacent indices (even with odd, odd with even)
pair_index = button_index - 1 if button_index % 2 == 1 else button_index + 1
if 0 <= pair_index < len(parent.children):
parent.remove_widget(parent.children[pair_index])
parent.remove_widget(button)
# message = [{"cmd": 'LocationChecks', "locations": [location_id]}]
# self.ctx.send_msgs(message)
def location_scout_callback(self, location_id: int, button: TreeViewButton) -> None:
# if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath
if self.are_top_controls_at_mouse_pos():
# if there's an obj in the top controls/tab at the current mouse position, click it instead
if hovered_obj := self.get_top_obj_at_mouse_pos():
if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc.
hovered_obj.trigger_action(duration=0)
elif hasattr(hovered_obj, 'focus'): # text inputs
hovered_obj.focus = True
return
if location_id:
self.ctx.locations_scouted.append(location_id)
self.ctx.syncing = True
def victory_button_callback(self, button: TreeViewButton):
# if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath
if self.are_top_controls_at_mouse_pos():
# if there's an obj in the top controls/tab at the current mouse position, click it instead
if hovered_obj := self.get_top_obj_at_mouse_pos():
if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc.
hovered_obj.trigger_action(duration=0)
elif hasattr(hovered_obj, 'focus'): # text inputs
hovered_obj.focus = True
return
if tracker_loaded and self.ctx.block_unreachable_location_press and "__Victory__" not in self.ctx.tracker_reachable_events:
logger.debug(f"button for location '{button.text}' was pressed while unreachable")
else:
self.ctx.goaled = True
self.ctx.syncing = True
return ManualManager
async def game_watcher_manual(ctx: ManualContext):
while not ctx.exit_event.is_set():
if ctx.ui:
ctx.ui.check_for_requested_update()
if ctx.syncing == True:
sync_msg = []
if ctx.locations_checked:
await ctx.check_locations(ctx.locations_checked)
if ctx.locations_scouted:
sync_msg.append({"cmd": "LocationScouts", "locations": list(ctx.locations_scouted), "create_as_hint": 2})
if sync_msg:
await ctx.send_msgs(sync_msg)
ctx.syncing = False
if ctx.set_deathlink:
ctx.set_deathlink = False
await ctx.update_death_link(True)
if ctx.deathlink_out:
ctx.deathlink_out = False
await ctx.send_death()
victory = ctx.goaled
ctx.locations_checked = []
ctx.locations_scouted = []
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
def read_apmanual_file(apmanual_file) -> dict[str, Any]:
import zipfile
from base64 import b64decode
from .container import APManualFile
if zipfile.is_zipfile(apmanual_file):
try:
container = APManualFile(apmanual_file)
container.read()
return container.get_manifest()
except Exception as e:
logger.exception("Error reading APManual file:", e)
with open(apmanual_file, "r") as f:
return json.loads(b64decode(f.read()))
async def main(args):
config_file = {}
if args.apmanual_file and os.path.exists(args.apmanual_file):
config_file = read_apmanual_file(args.apmanual_file)
if config_file.get("server") and not args.connect:
args.connect = config_file["server"]
ctx = ManualContext(args.connect, args.password, config_file.get("game"), config_file.get("player_name"))
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
ctx.item_table = config_file.get("items", {})
ctx.location_table = config_file.get("locations", {})
ctx.region_table = config_file.get("regions", {})
ctx.category_table = config_file.get("categories", {})
if tracker_loaded:
ctx.run_generator()
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
game_watcher_manual(ctx), name="ManualProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
def launch(*launch_args) -> None:
import colorama
parser = get_base_parser(description="Manual Client, for operating a Manual game in Archipelago.")
parser.add_argument('apmanual_file', default="", type=str, nargs="?",
help='Path to an APMANUAL file')
args, rest = parser.parse_known_args(launch_args)
colorama.init()
asyncio.run(main(args))
colorama.deinit()
if not os.path.exists(icon_paths["manual"]):
# Download the icon for next time
icon_url = "https://manualforarchipelago.github.io/ManualBuilder/images/ap-manual-discord-logo-square-96x96.png"
with open(icon_paths["manual"], 'wb') as f:
f.write(requests.get(icon_url).content)
if __name__ == '__main__':
launch()