Sync from development - prepare for v0.4.0

This commit is contained in:
Omni
2026-02-25 17:40:43 +00:00
parent 2eb54b9a36
commit 805718222a
324 changed files with 4914 additions and 4567 deletions

View File

@@ -1,13 +1,59 @@
"""TTW integration methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import signal
import shutil
from pathlib import Path
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
logger = logging.getLogger(__name__)
def _strip_ansi_control_codes(text: str) -> str:
"""Strip ANSI escape/control sequences from CLI output lines."""
return re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', text or '')
def prompt_ttw_if_eligible(install_dir: str, modlist_name: str) -> None:
"""Standalone TTW prompt usable outside the mixin context (e.g. CLI configure command).
Detects game type from ModOrganizer.ini, resolves the best available modlist name,
checks whitelist eligibility, and runs the interactive TTW prompt if applicable.
"""
try:
# Detect game type from ModOrganizer.ini
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
game_type = "skyrim"
if mo2_ini.exists():
content = mo2_ini.read_text(encoding="utf-8", errors="ignore").lower()
if "nvse_loader.exe" in content or "fallout new vegas" in content:
game_type = "falloutnv"
elif "fose_loader.exe" in content or "fallout 3" in content:
game_type = "fallout3"
if game_type not in ("falloutnv", "fallout_new_vegas"):
return
# Best available name: meta file, then selected_profile, then caller-supplied name
from jackify.backend.utils.modlist_meta import get_modlist_name
identified_name = get_modlist_name(install_dir) or modlist_name
if not identified_name:
return
class _Adapter(ModlistInstallCLITTWMixin):
def __init__(self):
self.logger = logging.getLogger(__name__)
self.verbose = False
self.filesystem_handler = None
self.config_handler = None
_Adapter()._check_and_prompt_ttw_integration(install_dir, game_type, identified_name)
except Exception as e:
logger.error("TTW post-configure check failed: %s", e, exc_info=True)
class ModlistInstallCLITTWMixin:
"""Mixin providing TTW integration methods."""
@@ -26,7 +72,38 @@ class ModlistInstallCLITTWMixin:
print(f"TTW combines Fallout 3 and New Vegas into a single game.")
print(f"\nWould you like to install TTW now?")
user_input = input(f"{COLOR_PROMPT}Install TTW? (yes/no): {COLOR_RESET}").strip().lower()
# Some CLI entrypoint signal handlers currently call sys.exit(), which can interrupt
# this prompt unexpectedly. Temporarily convert SIGINT/SIGTERM to KeyboardInterrupt
# and keep prompting so users can answer explicitly.
original_sigint = signal.getsignal(signal.SIGINT)
original_sigterm = signal.getsignal(signal.SIGTERM)
def _prompt_signal_handler(signum, frame):
raise KeyboardInterrupt
try:
signal.signal(signal.SIGINT, _prompt_signal_handler)
signal.signal(signal.SIGTERM, _prompt_signal_handler)
while True:
try:
user_input = input(f"{COLOR_PROMPT}Install TTW now? (Y/n): {COLOR_RESET}").strip().lower()
except KeyboardInterrupt:
print(f"\n{COLOR_WARNING}TTW prompt interrupted. Please type yes or no.{COLOR_RESET}")
continue
except EOFError:
print(f"\n{COLOR_WARNING}No input available. Skipping TTW installation.{COLOR_RESET}")
return
if user_input == "":
user_input = "y"
if user_input in ['yes', 'y', 'no', 'n']:
break
print(f"{COLOR_WARNING}Please answer yes or no.{COLOR_RESET}")
finally:
signal.signal(signal.SIGINT, original_sigint)
signal.signal(signal.SIGTERM, original_sigterm)
if user_input in ['yes', 'y']:
self._launch_ttw_installation(modlist_name, install_dir)
@@ -106,15 +183,26 @@ class ModlistInstallCLITTWMixin:
# Import TTW installation handler
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.services.platform_detection_service import PlatformDetectionService
from pathlib import Path
system_info = SystemInfo()
is_steamdeck = bool(getattr(self, 'steamdeck', False))
if not is_steamdeck:
try:
is_steamdeck = PlatformDetectionService.get_instance().is_steamdeck
except Exception:
is_steamdeck = False
filesystem_handler = getattr(self, 'filesystem_handler', None) or FileSystemHandler()
config_handler = getattr(self, 'config_handler', None) or ConfigHandler()
ttw_installer_handler = TTWInstallerHandler(
steamdeck=system_info.is_steamdeck if hasattr(system_info, 'is_steamdeck') else False,
steamdeck=is_steamdeck,
verbose=self.verbose if hasattr(self, 'verbose') else False,
filesystem_handler=self.filesystem_handler if hasattr(self, 'filesystem_handler') else None,
config_handler=self.config_handler if hasattr(self, 'config_handler') else None
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Check if TTW_Linux_Installer is installed
@@ -122,7 +210,9 @@ class ModlistInstallCLITTWMixin:
if not ttw_installer_handler.ttw_installer_installed:
print(f"{COLOR_INFO}TTW_Linux_Installer is not installed.{COLOR_RESET}")
user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (yes/no): {COLOR_RESET}").strip().lower()
user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (Y/n): {COLOR_RESET}").strip().lower()
if user_input == "":
user_input = "y"
if user_input not in ['yes', 'y']:
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
@@ -152,7 +242,7 @@ class ModlistInstallCLITTWMixin:
# Prompt for TTW installation directory
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
default_ttw_dir = os.path.join(install_dir, 'TTW')
default_ttw_dir = os.path.join(install_dir, 'mods', '[NoDelete] Tale of Two Wastelands')
print(f"Default: {default_ttw_dir}")
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
@@ -162,14 +252,105 @@ class ModlistInstallCLITTWMixin:
# Run TTW installation
print(f"\n{COLOR_INFO}Installing TTW using TTW_Linux_Installer...{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}")
phase_state = {"current": "Processing", "last_rendered": ""}
progress_line_active = {"value": False}
success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir))
def _ttw_output_callback(line: str):
clean = _strip_ansi_control_codes(line or "").strip()
if not clean:
return
lower = clean.lower()
rendered = ""
# Match GUI behavior: explicit Loading manifest counter line
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower)
if manifest_match:
current = int(manifest_match.group(1))
total = int(manifest_match.group(2))
phase_state["current"] = "Loading manifest"
percent = int((current / total) * 100) if total > 0 else 0
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
else:
# Match GUI behavior: generic [X/Y] counters with current phase name.
progress_match = re.search(r'\[(\d+)/(\d+)\]', clean)
if progress_match:
current = int(progress_match.group(1))
total = int(progress_match.group(2))
percent = int((current / total) * 100) if total > 0 else 0
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
else:
# Update phase state from milestone-like lines, then echo milestones.
if 'manifest' in lower:
phase_state["current"] = "Loading manifest"
elif any(token in lower for token in ('extract', 'decompress', 'installing', 'copying', 'merge')):
phase_state["current"] = clean
is_milestone = any(token in lower for token in ('===', 'complete', 'finished', 'starting', 'valid'))
is_error = 'error:' in lower
is_warning = 'warning:' in lower
if is_milestone or is_error or is_warning:
rendered = f"[TTW] {clean}"
if not rendered or rendered == phase_state["last_rendered"]:
return
phase_state["last_rendered"] = rendered
if rendered.startswith("[TTW] Loading manifest:") or re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered):
# In-place progress updates for counters/phases.
print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True)
progress_line_active["value"] = True
else:
# Non-progress milestones/errors get normal line output.
if progress_line_active["value"]:
print()
progress_line_active["value"] = False
print(f"{COLOR_INFO}{rendered}{COLOR_RESET}")
success, message = ttw_installer_handler.install_ttw_backend_with_output_stream(
Path(mpi_path),
Path(ttw_install_dir),
output_callback=_ttw_output_callback,
)
if progress_line_active["value"]:
print()
if success:
ttw_output_path = Path(ttw_install_dir)
ttw_version = ""
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', Path(mpi_path).stem, re.IGNORECASE)
if version_match:
ttw_version = version_match.group(1)
skip_copy = False
mods_dir = Path(install_dir) / "mods"
if ttw_output_path.parent == mods_dir:
versioned_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}".strip() if ttw_version else "[NoDelete] Tale of Two Wastelands"
versioned_path = mods_dir / versioned_name
if ttw_output_path != versioned_path and ttw_output_path.exists():
if versioned_path.exists():
shutil.rmtree(versioned_path)
ttw_output_path.rename(versioned_path)
ttw_output_path = versioned_path
skip_copy = True
print(f"\n{COLOR_INFO}Integrating TTW into modlist load order...{COLOR_RESET}")
integration_success = TTWInstallerHandler.integrate_ttw_into_modlist(
ttw_output_path=ttw_output_path,
modlist_install_dir=Path(install_dir),
ttw_version=ttw_version,
skip_copy=skip_copy,
)
if not integration_success:
print(f"{COLOR_ERROR}TTW installed, but integration into modlist failed.{COLOR_RESET}")
print(f"{COLOR_ERROR}Please check TTW_Install_workflow.log for details.{COLOR_RESET}")
return
print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nTTW has been installed to: {ttw_install_dir}")
print(f"\nTTW has been installed to: {ttw_output_path}")
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
else:
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
@@ -177,4 +358,4 @@ class ModlistInstallCLITTWMixin:
except Exception as e:
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")