1548 lines
79 KiB
Python
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()
|