From bc87f106618eda4b34e22f55b0c41ef6cd3086ed Mon Sep 17 00:00:00 2001 From: m4ko Date: Sun, 30 Aug 2026 22:15:32 +0200 Subject: [PATCH] Added server status and server selection dropdown. --- ptlinux/runtime.py | 75 ++++++- ptlinux/settings.py | 8 + ptlinux/ui.py | 480 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 513 insertions(+), 50 deletions(-) diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 1554047..f8176ac 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -7,19 +7,22 @@ from datetime import datetime import hashlib import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import platform +import shlex import shutil import sys from typing import Mapping, Sequence from .command import CommandOutcome, CommandRunner, OutputLineFilter +from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption from .gamebox import build_gamebox_command from .paths import AppPaths from .settings import Settings -WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe" +WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU" +WINDOWS_GAME_EXE = rf"{WINDOWS_GAME_DIRECTORY}\Game.exe" NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43") DXVK_D3D9_OVERRIDE = "native,builtin" DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides" @@ -293,6 +296,7 @@ def run_game( selection: RuntimeSelection, runner: CommandRunner, *, + start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION, use_gamebox: bool = True, timeout_seconds: float = 12 * 60 * 60, check: bool = True, @@ -301,11 +305,15 @@ def run_game( ) -> CommandOutcome: if not paths.runtime_view.is_dir() and not runner.dry_run: raise RuntimeErrorPT("runtime view is missing; prepare the game first") + native_executable, windows_executable, game_arguments = _game_start_command( + paths, + start_option, + ) if selection.kind == "umu": game_command = [ selection.executable, - str(paths.runtime_view / "Game.exe"), - "PTEU_REBORN", + native_executable, + *game_arguments, ] environment = _umu_environment(paths) elif selection.kind == "proton7-container": @@ -313,8 +321,8 @@ def run_game( raise RuntimeErrorPT("Proton script path is missing") game_command = [ selection.executable, - str(paths.runtime_view / "Game.exe"), - "PTEU_REBORN", + native_executable, + *game_arguments, ] environment = _umu_proton7_environment( paths, @@ -330,8 +338,8 @@ def run_game( "ptlinux.proton_compat", selection.proton_path, "runinprefix", - WINDOWS_GAME_EXE, - "PTEU_REBORN", + windows_executable, + *game_arguments, ] environment = _proton_environment( paths, @@ -356,6 +364,57 @@ def run_game( ) +def _game_start_command( + paths: AppPaths, + option: DepotStartOption, +) -> tuple[str, str, tuple[str, ...]]: + relative = PurePosixPath(option.path) + if ( + relative.is_absolute() + or not relative.parts + or any(part in {"", ".", ".."} for part in relative.parts) + or relative.suffix.casefold() != ".exe" + ): + raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}") + try: + arguments = tuple(shlex.split(option.arguments)) + except ValueError as exc: + raise RuntimeErrorPT( + f"invalid launch arguments for realm {option.name}: {exc}" + ) from exc + if not arguments: + raise RuntimeErrorPT(f"realm {option.name} has no launch arguments") + native = str(_resolve_runtime_path_case(paths.runtime_view, relative)) + windows_relative = "\\".join(relative.parts) + return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments + + +def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path: + """Match Windows manifest casing against the case-sensitive runtime view.""" + + direct = root.joinpath(*relative.parts) + if direct.exists(): + return direct + cursor = root + for part in relative.parts: + if not cursor.is_dir(): + return direct + try: + matches = tuple( + entry for entry in cursor.iterdir() if entry.name.casefold() == part.casefold() + ) + except OSError: + return direct + if not matches: + return direct + if len(matches) > 1: + raise RuntimeErrorPT( + f"realm executable path has ambiguous casing: {relative.as_posix()}" + ) + cursor = matches[0] + return cursor + + def run_proton_prefix_command( paths: AppPaths, selection: RuntimeSelection, diff --git a/ptlinux/settings.py b/ptlinux/settings.py index 709dee2..30c2533 100644 --- a/ptlinux/settings.py +++ b/ptlinux/settings.py @@ -19,6 +19,7 @@ SETTINGS_SCHEMA = 1 @dataclass(frozen=True) class Settings: + realm: str = "Babel" width: int = 1600 height: int = 900 output_width: int = 1600 @@ -133,6 +134,7 @@ def _read_settings_object(path: Path) -> dict[str, object] | None: def _settings_from_object(value: dict[str, object]) -> Settings: settings = Settings( + realm=_string(value, "realm", "Babel").strip(), width=_integer(value, "width", 1600), height=_integer(value, "height", 900), output_width=_integer(value, "output_width", 1600), @@ -215,6 +217,12 @@ def _fsync_directory(path: Path) -> None: def _validate(settings: Settings) -> None: + if ( + len(settings.realm) > 64 + or not settings.realm.strip() + or any(ord(character) < 32 for character in settings.realm) + ): + raise ValueError("realm must be a non-empty display name") for name, value in ( ("width", settings.width), ("height", settings.height), diff --git a/ptlinux/ui.py b/ptlinux/ui.py index 57939be..70beec3 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -17,7 +17,16 @@ import webbrowser from typing import Callable, Mapping from . import __version__ -from .depot import DepotClient, DepotError, DepotSnapshot +from .depot import ( + DEFAULT_DEPOT_START_OPTION, + DepotClient, + DepotError, + DepotSnapshot, + DepotStartOption, + cache_depot_launch_catalog, + launch_catalog_from_snapshot, + select_depot_start_option, +) from .desktop_identity import ( APPLICATION_NAME, APPLICATION_WM_CLASS, @@ -48,6 +57,11 @@ from .self_update import ( check_for_launcher_update, read_cached_launcher_update_check, ) +from .server_status import ( + RealmServerStatus, + ServerStatusError, + fetch_server_status, +) MAX_NEWS_CARDS = 3 @@ -70,6 +84,7 @@ DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000 DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 DASHBOARD_TRANSITION_STEPS = 9 DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18 +PRELOADER_STATUS_CHARACTERS = 64 DASHBOARD_STARTUP_COMPONENTS = frozenset( { "broadcast", @@ -78,6 +93,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset( "logo", "news", "news_artwork", + "server_status", "version", } ) @@ -92,6 +108,22 @@ class LauncherUpdatePresentation: badge_text: str | None +@dataclass(frozen=True) +class GameSyncPresentation: + """User-facing wording for one managed-game synchronization.""" + + operation_label: str + file_progress_verb: str + + +@dataclass(frozen=True) +class ServerStatusPresentation: + """Text and Tk style for one official realm-health result.""" + + text: str + style: str + + class DashboardError(RuntimeError): """Raised when the native dashboard cannot be displayed.""" @@ -148,6 +180,61 @@ def game_client_activity_text(running_count: int) -> str: return f"{running_count} {label} running" +def game_sync_presentation( + *, + installed: bool, + repair: bool = False, +) -> GameSyncPresentation: + """Distinguish a fresh download from an update or repair.""" + + if not installed: + return GameSyncPresentation( + operation_label=( + "Installing and verifying official game files…" + if repair + else "Installing official game files…" + ), + file_progress_verb="Downloading", + ) + if repair: + return GameSyncPresentation( + operation_label="Checking every game file against the official depot…", + file_progress_verb="Repairing", + ) + return GameSyncPresentation( + operation_label="Synchronizing official game files…", + file_progress_verb="Updating", + ) + + +def game_file_progress_status(current_file: str, verb: str) -> str: + """Render the current depot asset with the operation's exact intent.""" + + return f"{verb} {current_file}" + + +def server_status_presentation(status: RealmServerStatus) -> ServerStatusPresentation: + if status.online is True: + players = ( + f" · {status.players_online} online" + if status.players_online is not None + else " · online" + ) + return ServerStatusPresentation( + text=f"● {status.name}{players}", + style="ServerOnline.TLabel", + ) + if status.online is False: + return ServerStatusPresentation( + text=f"● {status.name} · offline", + style="ServerOffline.TLabel", + ) + return ServerStatusPresentation( + text=f"○ {status.name} · status unknown", + style="ServerUnknown.TLabel", + ) + + def launcher_update_presentation( check: LauncherUpdateCheck, ) -> LauncherUpdatePresentation: @@ -200,6 +287,29 @@ def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float: return 1.0 - opacity_range * progress +def preloader_status_text( + value: str, + step: int | None = None, + total: int | None = None, +) -> str: + """Render one launch stage without letting its widget change shape.""" + + if step is None or total is None: + return value + return f"Step {step} of {total} · {value}" + + +def freeze_preloader_geometry(frame: tk.Misc) -> tuple[int, int]: + """Lock a measured preloader frame so later status text cannot reflow it.""" + + frame.update_idletasks() + width = max(1, frame.winfo_reqwidth()) + height = max(1, frame.winfo_reqheight()) + frame.configure(width=width, height=height) + frame.pack_propagate(False) + return width, height + + def launcher_restart_spec( paths: AppPaths, environment: Mapping[str, str] | None = None, @@ -228,6 +338,12 @@ def launcher_restart_spec( return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env +def launcher_child_command(*arguments: str) -> list[str]: + """Build a child command without writing bytecode into the active release.""" + + return [sys.executable, "-B", "-m", "ptlinux", *arguments] + + class ProgressCoalescer: """Keep only the latest cross-thread progress event for the Tk loop. @@ -347,7 +463,7 @@ def run_dashboard(paths: AppPaths) -> None: root.configure(background="#0c0908") root.withdraw() try: - ensure_settings(paths) + startup_settings = ensure_settings(paths) except (OSError, ValueError) as startup_error: root.deiconify() messagebox.showerror( @@ -425,6 +541,37 @@ def run_dashboard(paths: AppPaths) -> None: foreground="#aa9a8b", font=("Sans", 8), ) + style.configure( + "ServerHeading.TLabel", + background="#191311", + foreground="#d8aa61", + font=("Sans", 8, "bold"), + padding=(0, 3), + ) + style.configure( + "ServerOnline.TLabel", + background="#102719", + foreground="#58e27a", + font=("Sans", 8, "bold"), + padding=(8, 3), + anchor="w", + ) + style.configure( + "ServerOffline.TLabel", + background="#301514", + foreground="#ff6258", + font=("Sans", 8, "bold"), + padding=(8, 3), + anchor="w", + ) + style.configure( + "ServerUnknown.TLabel", + background="#211916", + foreground="#b8a99a", + font=("Sans", 8), + padding=(8, 3), + anchor="w", + ) style.configure( "Speed.TLabel", background="#191311", @@ -494,6 +641,56 @@ def run_dashboard(paths: AppPaths) -> None: fieldbackground=[("readonly", "#100d0c")], foreground=[("readonly", "#f8ead2")], ) + style.configure( + "Realm.TCombobox", + fieldbackground="#211916", + background="#211916", + foreground="#fff0d2", + arrowcolor="#d8aa61", + bordercolor="#4a3528", + lightcolor="#4a3528", + darkcolor="#4a3528", + font=("Sans", 10, "bold"), + padding=(10, 7), + relief="flat", + ) + style.map( + "Realm.TCombobox", + fieldbackground=[ + ("disabled", "#171412"), + ("readonly", "#211916"), + ], + background=[ + ("disabled", "#171412"), + ("active", "#2a201c"), + ("readonly", "#211916"), + ], + foreground=[ + ("disabled", "#887a70"), + ("readonly", "#fff0d2"), + ], + arrowcolor=[ + ("disabled", "#887a70"), + ("active", "#f0d7a2"), + ("readonly", "#d8aa61"), + ], + bordercolor=[ + ("disabled", "#4a3528"), + ("focus", "#6a4d38"), + ("readonly", "#4a3528"), + ], + ) + root.option_add("*realm_selector*Listbox.background", "#211916") + root.option_add("*realm_selector*Listbox.foreground", "#fff0d2") + root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c") + root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2") + root.option_add("*realm_selector*Listbox.font", "Sans 10 bold") + root.option_add("*realm_selector*Listbox.justify", "center") + root.option_add("*realm_selector*Listbox.selectBorderWidth", 4) + root.option_add("*realm_selector*Listbox.borderWidth", 1) + root.option_add("*realm_selector*Listbox.highlightThickness", 0) + root.option_add("*realm_selector*Listbox.relief", "flat") + root.option_add("*realm_selector*Listbox.activeStyle", "none") style.configure( "Settings.TCheckbutton", background="#191311", @@ -665,11 +862,14 @@ def run_dashboard(paths: AppPaths) -> None: text="NATIVE LINUX LAUNCHER", style="SplashTagline.TLabel", ).pack(anchor="w", pady=(2, 17)) - ttk.Label( + startup_status_label = ttk.Label( startup_shell, textvariable=startup_status, style="SplashStatus.TLabel", - ).pack(anchor="w", pady=(0, 8)) + width=PRELOADER_STATUS_CHARACTERS, + anchor="w", + ) + startup_status_label.pack(fill="x", pady=(0, 8)) startup_progress = ttk.Progressbar( startup_shell, style="Depot.Horizontal.TProgressbar", @@ -677,6 +877,7 @@ def run_dashboard(paths: AppPaths) -> None: length=420, ) startup_progress.pack(fill="x") + freeze_preloader_geometry(startup_border) startup_progress.start(12) startup_ready_components: set[str] = set() startup_transition_started = False @@ -921,8 +1122,25 @@ def run_dashboard(paths: AppPaths) -> None: activity_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 7)) activity_panel.pack(fill="x", pady=(0, 8)) + realm_value = tk.StringVar(value=DEFAULT_DEPOT_START_OPTION.name) + activity_action = ttk.Frame(activity_panel, style="Surface.TFrame") + activity_action.pack(side="right", fill="y", padx=(12, 0)) + play_button = ttk.Button(activity_action, text="PLAY", style="Play.TButton") + play_button.pack(fill="both", expand=True) + realm_selector = ttk.Combobox( + activity_action, + name="realm_selector", + textvariable=realm_value, + values=(DEFAULT_DEPOT_START_OPTION.name,), + state="readonly", + style="Realm.TCombobox", + font=("Sans", 10, "bold"), + justify="center", + width=20, + ) + realm_selector.pack(fill="x", pady=(5, 0)) activity_copy = ttk.Frame(activity_panel, style="Surface.TFrame") - activity_copy.pack(side="left", fill="x", expand=True, padx=(0, 12)) + activity_copy.pack(side="left", fill="both", expand=True) status = tk.StringVar(value="Ready") progress_text = tk.StringVar(value="Click Play to start your journey") speed_text = tk.StringVar(value="") @@ -962,8 +1180,20 @@ def run_dashboard(paths: AppPaths) -> None: style="Muted.TLabel", anchor="w", ).pack(side="left", fill="x", expand=True) - play_button = ttk.Button(activity_panel, text="PLAY", style="Play.TButton") - play_button.pack(side="right", fill="y") + server_status_row = ttk.Frame(activity_copy, style="Surface.TFrame") + server_status_row.pack(fill="x", pady=(5, 1)) + ttk.Label( + server_status_row, + text="SERVERS", + style="ServerHeading.TLabel", + ).pack(side="left", fill="y", padx=(0, 8)) + server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame") + server_status_content.pack(side="left", fill="y", anchor="w") + ttk.Label( + server_status_content, + text="Checking official server status…", + style="ServerUnknown.TLabel", + ).pack(side="left", anchor="w") news_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) news_panel.pack(fill="x", pady=(0, 8), before=controls) @@ -1054,8 +1284,13 @@ def run_dashboard(paths: AppPaths) -> None: operation_stage_total = 0 operation_message = "" operation_refreshes_compatibility = False + operation_file_progress_verb = "Updating" last_elapsed_second = -1 official_snapshot: DepotSnapshot | None = None + available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) + preferred_realm = startup_settings.realm + latest_server_statuses: tuple[RealmServerStatus, ...] | None = None + server_status_placeholder = "Checking official server status…" launcher_update_check: LauncherUpdateCheck | None = None runtime_compatibility: RuntimeCompatibility | None = None compatibility_check_generation = 0 @@ -1106,6 +1341,7 @@ def run_dashboard(paths: AppPaths) -> None: new_state = "disabled" if value else "normal" for button in buttons: button.configure(state=new_state) + realm_selector.configure(state="disabled" if value else "readonly") if not value and game_clients.running_count: for button in game_mutation_buttons: button.configure(state="disabled") @@ -1143,7 +1379,9 @@ def run_dashboard(paths: AppPaths) -> None: transfer_rate.reset() speed_text.set("") if isinstance(current_file, str) and current_file: - status.set(f"Updating {current_file}") + status.set( + game_file_progress_status(current_file, operation_file_progress_verb) + ) elif phase == "complete": status.set("Game files are up to date and verified.") @@ -1214,8 +1452,15 @@ def run_dashboard(paths: AppPaths) -> None: text="Please wait while the game prepares its window.", style="PreloaderStatus.TLabel", ).pack(anchor="w") - ttk.Label(content, textvariable=status_variable, style="PreloaderMuted.TLabel").pack( + status_label = ttk.Label( + content, + textvariable=status_variable, + style="PreloaderMuted.TLabel", + width=PRELOADER_STATUS_CHARACTERS, anchor="w", + ) + status_label.pack( + fill="x", pady=(4, 10), ) progress_widget = ttk.Progressbar( @@ -1227,6 +1472,7 @@ def run_dashboard(paths: AppPaths) -> None: ) preloader_progress = progress_widget progress_widget.pack(fill="x") + freeze_preloader_geometry(content) window.update_idletasks() width = window.winfo_reqwidth() height = window.winfo_reqheight() @@ -1237,10 +1483,7 @@ def run_dashboard(paths: AppPaths) -> None: def set_preloader_status(value: str, step: int | None = None, total: int | None = None) -> None: if preloader_status is not None: - if step is not None and total is not None: - preloader_status.set(f"Step {step} of {total} · {value}") - else: - preloader_status.set(value) + preloader_status.set(preloader_status_text(value, step, total)) if preloader_progress is not None and step is not None and total is not None: preloader_progress.configure(maximum=total, value=step) @@ -1259,6 +1502,7 @@ def run_dashboard(paths: AppPaths) -> None: show_preloader: bool = False, restart_on_success: bool = False, game_files_may_change: bool = False, + file_progress_verb: str = "Updating", game_client_id: int | None = None, ) -> None: nonlocal operation_active, game_window_active @@ -1267,6 +1511,7 @@ def run_dashboard(paths: AppPaths) -> None: nonlocal operation_stage_total nonlocal operation_message nonlocal operation_refreshes_compatibility + nonlocal operation_file_progress_verb nonlocal last_elapsed_second operation_active = True game_window_active = game_clients.running_count > 0 @@ -1275,6 +1520,7 @@ def run_dashboard(paths: AppPaths) -> None: operation_stage_total = 0 operation_message = "" operation_refreshes_compatibility = game_files_may_change + operation_file_progress_verb = file_progress_verb last_elapsed_second = -1 progress_updates.clear() transfer_rate.reset() @@ -1658,6 +1904,102 @@ def run_dashboard(paths: AppPaths) -> None: threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start() + def render_server_status() -> None: + for child in server_status_content.winfo_children(): + child.destroy() + if latest_server_statuses is None: + ttk.Label( + server_status_content, + text=server_status_placeholder, + style="ServerUnknown.TLabel", + ).pack(side="left", anchor="w") + return + statuses_by_name = { + item.name.casefold(): item for item in latest_server_statuses + } + visible = [ + statuses_by_name[option.name.casefold()] + for option in available_start_options + if option.name.casefold() in statuses_by_name + ] + if not visible: + ttk.Label( + server_status_content, + text="Official status is unavailable for this realm.", + style="ServerUnknown.TLabel", + ).pack(side="left", anchor="w") + return + for index, server in enumerate(visible): + presentation = server_status_presentation(server) + ttk.Label( + server_status_content, + text=presentation.text, + style=presentation.style, + ).pack( + side="left", + anchor="w", + padx=(0 if index == 0 else 6, 0), + ) + + def show_server_status( + statuses: tuple[RealmServerStatus, ...] | None, + *, + placeholder: str = "Server status unavailable.", + ) -> None: + nonlocal latest_server_statuses, server_status_placeholder + latest_server_statuses = statuses + server_status_placeholder = placeholder + render_server_status() + + def install_realm_options(snapshot: DepotSnapshot) -> None: + nonlocal available_start_options, preferred_realm + catalog = launch_catalog_from_snapshot(snapshot) + available_start_options = catalog.start_options + selected = select_depot_start_option(catalog, preferred_realm) + realm_selector.configure( + values=tuple(option.name for option in available_start_options), + state="disabled" if operation_active else "readonly", + ) + realm_value.set(selected.name) + if selected.name != preferred_realm: + preferred_realm = selected.name + try: + update_settings(paths, realm=selected.name) + except (OSError, ValueError) as save_error: + append_log(f"Could not save the selected realm: {save_error}") + render_server_status() + + def on_realm_selected(_event: object | None = None) -> None: + nonlocal preferred_realm + selected_name = realm_value.get() + selected = next( + ( + option + for option in available_start_options + if option.name.casefold() == selected_name.casefold() + ), + None, + ) + if selected is None: + fallback = select_depot_start_option( + launch_catalog_from_snapshot(official_snapshot) + if official_snapshot is not None + else None, + preferred_realm, + ) + realm_value.set(fallback.name) + return + realm_value.set(selected.name) + if preferred_realm == selected.name: + return + preferred_realm = selected.name + try: + update_settings(paths, realm=selected.name) + except (OSError, ValueError) as save_error: + append_log(f"Could not save the selected realm: {save_error}") + + realm_selector.bind("<>", on_realm_selected) + def refresh_primary_action( snapshot: DepotSnapshot | None = None, *, @@ -1802,16 +2144,38 @@ def run_dashboard(paths: AppPaths) -> None: def news_worker() -> None: events.put(("news", fetch_news(paths.news_cache))) + def server_status_worker() -> None: + try: + statuses = fetch_server_status() + except ServerStatusError as status_error: + events.put(("server_status_error", str(status_error))) + else: + events.put(("server_status", statuses)) + def version_worker() -> None: try: snapshot = DepotClient().fetch_snapshot() except DepotError as remote_error: events.put(("version_error", str(remote_error))) else: + try: + cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot) + except OSError as cache_error: + events.put( + ( + "output", + f"Could not cache official realm choices: {cache_error}\n", + ) + ) events.put(("version", snapshot)) threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start() threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start() + threading.Thread( + target=server_status_worker, + name="ptlinux-server-status", + daemon=True, + ).start() threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() refresh_launcher_update_check() @@ -1968,6 +2332,23 @@ def run_dashboard(paths: AppPaths) -> None: "broadcast", "Broadcast ready", ) + elif ( + kind == "server_status" + and isinstance(value, tuple) + and all(isinstance(item, RealmServerStatus) for item in value) + ): + show_server_status(value) + mark_startup_component_ready( + "server_status", + "Server status ready", + ) + elif kind == "server_status_error" and isinstance(value, str): + show_server_status(None) + append_log(f"Server status check unavailable: {value}") + mark_startup_component_ready( + "server_status", + "Server status check complete", + ) elif kind == "news" and isinstance(value, tuple): show_news(value) mark_startup_component_ready( @@ -2013,6 +2394,7 @@ def run_dashboard(paths: AppPaths) -> None: version.set( f"Launcher {__version__} · {value.version} · depot {value.build}" ) + install_realm_options(value) refresh_primary_action(value) mark_startup_component_ready( "version", @@ -2074,7 +2456,7 @@ def run_dashboard(paths: AppPaths) -> None: root.after(poll_delay, poll_events) def command_for(*arguments: str) -> list[str]: - return [sys.executable, "-m", "ptlinux", *arguments] + return launcher_child_command(*arguments) def run_launcher_update(required_reason: str | None = None) -> None: check = launcher_update_check @@ -2128,15 +2510,51 @@ def run_dashboard(paths: AppPaths) -> None: def start_game_client() -> None: nonlocal active_game_launch_id + if official_snapshot is not None: + try: + cache_depot_launch_catalog( + paths.depot_launch_catalog_cache, + official_snapshot, + ) + except OSError as cache_error: + messagebox.showerror( + "Realm selection", + "The launcher could not save the official realm selection:\n\n" + f"{cache_error}", + parent=root, + ) + return client_id = game_clients.begin() active_game_launch_id = client_id run_operation( - f"Starting PristonTale EU client {client_id}…", - command_for("play"), + f"Starting PristonTale EU {realm_value.get()} client {client_id}…", + command_for("play", "--realm", realm_value.get()), show_preloader=True, game_client_id=client_id, ) + def synchronize_game_files( + installation: GameInstallationStatus | None = None, + *, + repair: bool = False, + ) -> None: + current = installation or refresh_primary_action(update_activity=False) + presentation = game_sync_presentation( + installed=current.installed, + repair=repair, + ) + command = ( + command_for("update", "--no-import", "--repair") + if repair + else command_for("update") + ) + run_operation( + presentation.operation_label, + command, + game_files_may_change=True, + file_progress_verb=presentation.file_progress_verb, + ) + def run_primary_action() -> None: installation = refresh_primary_action(update_activity=False) if ( @@ -2144,18 +2562,8 @@ def run_dashboard(paths: AppPaths) -> None: or (game_clients.running_count > 0 and installation.installed) ): start_game_client() - elif installation.action is PrimaryAction.INSTALL: - run_operation( - "Installing official game files…", - command_for("update"), - game_files_may_change=True, - ) - elif installation.action is PrimaryAction.UPDATE: - run_operation( - "Synchronizing official game files…", - command_for("update"), - game_files_may_change=True, - ) + elif installation.action in {PrimaryAction.INSTALL, PrimaryAction.UPDATE}: + synchronize_game_files(installation) elif installation.action is PrimaryAction.LAUNCHER_UPDATE: run_launcher_update( runtime_compatibility.reason @@ -2164,20 +2572,8 @@ def run_dashboard(paths: AppPaths) -> None: ) play_button.configure(command=run_primary_action) - update_button.configure( - command=lambda: run_operation( - "Synchronizing official game files…", - command_for("update"), - game_files_may_change=True, - ) - ) - repair_button.configure( - command=lambda: run_operation( - "Checking every game file against the official depot…", - command_for("update", "--no-import", "--repair"), - game_files_may_change=True, - ) - ) + update_button.configure(command=synchronize_game_files) + repair_button.configure(command=lambda: synchronize_game_files(repair=True)) doctor_button.configure( command=lambda: run_operation("Checking the system…", command_for("doctor")) )