#!/usr/bin/env python3 from __future__ import annotations import json import queue import subprocess import threading from pathlib import Path try: import tkinter as tk from tkinter import filedialog, messagebox, ttk except ModuleNotFoundError: print( "This Python install does not include Tkinter.\n" "Use the browser GUI instead:\n" " node scripts/iwt2-pvp-sim-gui-server.mjs\n" "Then open http://localhost:8787\n" ) raise SystemExit(1) ROOT = Path(__file__).resolve().parents[1] SIM_SCRIPT = ROOT / "scripts" / "iwt2-pvp-roguelike-boss-sim.mjs" HEALER_CLASSES = ["dawnweaver", "lifebinder", "runesage"] BOSS_IDS = [ "bulldrome", "yian-kut-ku", "great-jaggi", "khezu", "rathian", "barroth", "tobi-kadachi", "rimebastion", "ember-mantis-duelist", "cinderback-ricochet", "obsidian-ram-golem", "stormcoil-wyrm", "venom-orchid-hydra", "sandglass-scorpion", "crystal-bat-matriarch", "hollowcrown-revenant", ] class SimGui(tk.Tk): def __init__(self) -> None: super().__init__() self.title("IWT2 PvP Roguelike Sim") self.geometry("1120x780") self.minsize(920, 640) self.output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue() self.process: subprocess.Popen[str] | None = None self.last_json = "" self.class_vars = {name: tk.BooleanVar(value=True) for name in HEALER_CLASSES} self.boss_vars = {boss_id: tk.BooleanVar(value=True) for boss_id in BOSS_IDS} self.all_classes_var = tk.BooleanVar(value=True) self.all_bosses_var = tk.BooleanVar(value=True) self.gear_level_var = tk.StringVar(value="5") self.boss_count_var = tk.StringVar(value="2") self.hp_percent_var = tk.StringVar(value="100") self.damage_percent_var = tk.StringVar(value="100") self.stages_var = tk.StringVar(value="1,2") self.seconds_var = tk.StringVar(value="180") self.workers_var = tk.StringVar(value="8") self.top_var = tk.StringVar(value="24") self.status_var = tk.StringVar(value="Idle") self._build_ui() self.after(100, self._drain_output_queue) def _build_ui(self) -> None: self.columnconfigure(0, weight=0) self.columnconfigure(1, weight=1) self.rowconfigure(0, weight=1) controls = ttk.Frame(self, padding=12) controls.grid(row=0, column=0, sticky="nsew") output_frame = ttk.Frame(self, padding=(0, 12, 12, 12)) output_frame.grid(row=0, column=1, sticky="nsew") output_frame.columnconfigure(0, weight=1) output_frame.rowconfigure(1, weight=1) self._build_controls(controls) output_header = ttk.Frame(output_frame) output_header.grid(row=0, column=0, sticky="ew", pady=(0, 8)) output_header.columnconfigure(0, weight=1) ttk.Label(output_header, textvariable=self.status_var).grid(row=0, column=0, sticky="w") ttk.Button(output_header, text="Save JSON", command=self._save_json).grid(row=0, column=1, padx=(8, 0)) ttk.Button(output_header, text="Clear", command=self._clear_output).grid(row=0, column=2, padx=(8, 0)) self.output = tk.Text(output_frame, wrap="none", font=("Menlo", 11)) self.output.grid(row=1, column=0, sticky="nsew") yscroll = ttk.Scrollbar(output_frame, orient="vertical", command=self.output.yview) yscroll.grid(row=1, column=1, sticky="ns") xscroll = ttk.Scrollbar(output_frame, orient="horizontal", command=self.output.xview) xscroll.grid(row=2, column=0, sticky="ew") self.output.configure(yscrollcommand=yscroll.set, xscrollcommand=xscroll.set) def _build_controls(self, parent: ttk.Frame) -> None: parent.columnconfigure(0, weight=1) ttk.Label(parent, text="Simulation").grid(row=0, column=0, sticky="w") scalar_grid = ttk.Frame(parent) scalar_grid.grid(row=1, column=0, sticky="ew", pady=(8, 12)) for column in range(2): scalar_grid.columnconfigure(column, weight=1) fields = [ ("Gear level", self.gear_level_var), ("Boss count", self.boss_count_var), ("Boss HP %", self.hp_percent_var), ("Boss damage %", self.damage_percent_var), ("Stages", self.stages_var), ("Seconds", self.seconds_var), ("Workers", self.workers_var), ("Top rows", self.top_var), ] for index, (label, variable) in enumerate(fields): row = index // 2 column = (index % 2) * 2 ttk.Label(scalar_grid, text=label).grid(row=row, column=column, sticky="w", padx=(0, 6), pady=3) ttk.Entry(scalar_grid, textvariable=variable, width=10).grid(row=row, column=column + 1, sticky="ew", pady=3) ttk.Checkbutton( parent, text="All classes", variable=self.all_classes_var, command=self._toggle_all_classes, ).grid(row=2, column=0, sticky="w") class_frame = ttk.Frame(parent) class_frame.grid(row=3, column=0, sticky="ew", pady=(4, 12)) for index, class_id in enumerate(HEALER_CLASSES): ttk.Checkbutton( class_frame, text=class_id, variable=self.class_vars[class_id], command=self._sync_all_classes, ).grid(row=0, column=index, sticky="w", padx=(0, 10)) ttk.Checkbutton( parent, text="All bosses", variable=self.all_bosses_var, command=self._toggle_all_bosses, ).grid(row=4, column=0, sticky="w") boss_canvas = tk.Canvas(parent, height=300, highlightthickness=0) boss_scroll = ttk.Scrollbar(parent, orient="vertical", command=boss_canvas.yview) boss_frame = ttk.Frame(boss_canvas) boss_frame.bind( "", lambda _event: boss_canvas.configure(scrollregion=boss_canvas.bbox("all")), ) boss_canvas.create_window((0, 0), window=boss_frame, anchor="nw") boss_canvas.configure(yscrollcommand=boss_scroll.set) boss_canvas.grid(row=5, column=0, sticky="nsew", pady=(4, 12)) boss_scroll.grid(row=5, column=1, sticky="ns", pady=(4, 12)) for index, boss_id in enumerate(BOSS_IDS): ttk.Checkbutton( boss_frame, text=boss_id, variable=self.boss_vars[boss_id], command=self._sync_all_bosses, ).grid(row=index, column=0, sticky="w", pady=2) button_row = ttk.Frame(parent) button_row.grid(row=6, column=0, sticky="ew") button_row.columnconfigure(0, weight=1) self.run_button = ttk.Button(button_row, text="Run Simulation", command=self._start_sim) self.run_button.grid(row=0, column=0, sticky="ew") self.stop_button = ttk.Button(button_row, text="Stop", command=self._stop_sim, state="disabled") self.stop_button.grid(row=0, column=1, padx=(8, 0)) ttk.Button(parent, text="Copy Command", command=self._copy_command).grid(row=7, column=0, sticky="ew", pady=(8, 0)) def _toggle_all_classes(self) -> None: value = self.all_classes_var.get() for variable in self.class_vars.values(): variable.set(value) def _sync_all_classes(self) -> None: self.all_classes_var.set(all(variable.get() for variable in self.class_vars.values())) def _toggle_all_bosses(self) -> None: value = self.all_bosses_var.get() for variable in self.boss_vars.values(): variable.set(value) def _sync_all_bosses(self) -> None: self.all_bosses_var.set(all(variable.get() for variable in self.boss_vars.values())) def _selected_classes(self) -> str: if self.all_classes_var.get(): return "all" selected = [name for name, variable in self.class_vars.items() if variable.get()] return ",".join(selected) if selected else "all" def _selected_bosses(self) -> str: if self.all_bosses_var.get(): return "all" selected = [boss_id for boss_id, variable in self.boss_vars.items() if variable.get()] return ",".join(selected) if selected else "all" def _command(self) -> list[str]: return [ "node", str(SIM_SCRIPT), "--classes", self._selected_classes(), "--gear-level", self.gear_level_var.get().strip() or "5", "--boss-count", self.boss_count_var.get().strip() or "2", "--bosses", self._selected_bosses(), "--boss-hp-percent", self.hp_percent_var.get().strip() or "100", "--boss-damage-percent", self.damage_percent_var.get().strip() or "100", "--stages", self.stages_var.get().strip() or "1,2", "--seconds", self.seconds_var.get().strip() or "180", "--workers", self.workers_var.get().strip() or "8", "--top", self.top_var.get().strip() or "24", ] def _start_sim(self) -> None: if self.process is not None: return command = self._command() self._clear_output() self._append_output("$ " + " ".join(command) + "\n\n") self.status_var.set("Running...") self.run_button.configure(state="disabled") self.stop_button.configure(state="normal") thread = threading.Thread(target=self._run_subprocess, args=(command,), daemon=True) thread.start() def _run_subprocess(self, command: list[str]) -> None: try: self.process = subprocess.Popen( command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) assert self.process.stdout is not None chunks: list[str] = [] for line in self.process.stdout: chunks.append(line) self.output_queue.put(("line", line)) exit_code = self.process.wait() output = "".join(chunks) self.output_queue.put(("done", json.dumps({"exitCode": exit_code, "output": output}))) except Exception as exc: # noqa: BLE001 self.output_queue.put(("error", str(exc))) def _drain_output_queue(self) -> None: try: while True: kind, payload = self.output_queue.get_nowait() if kind == "line" and payload is not None: self._append_output(payload) elif kind == "done" and payload is not None: result = json.loads(payload) self.last_json = self._extract_json(result["output"]) self.status_var.set(f"Finished: exit {result['exitCode']}") self.process = None self.run_button.configure(state="normal") self.stop_button.configure(state="disabled") elif kind == "error" and payload is not None: self.status_var.set("Failed") self._append_output(f"\nERROR: {payload}\n") self.process = None self.run_button.configure(state="normal") self.stop_button.configure(state="disabled") except queue.Empty: pass self.after(100, self._drain_output_queue) def _stop_sim(self) -> None: if self.process is None: return self.process.terminate() self.status_var.set("Stopping...") def _clear_output(self) -> None: self.output.delete("1.0", "end") self.last_json = "" def _append_output(self, text: str) -> None: self.output.insert("end", text) self.output.see("end") def _copy_command(self) -> None: command = " ".join(self._command()) self.clipboard_clear() self.clipboard_append(command) self.status_var.set("Command copied") def _save_json(self) -> None: output = self.last_json or self._extract_json(self.output.get("1.0", "end")) if not output: messagebox.showinfo("No JSON", "Run a simulation first.") return path = filedialog.asksaveasfilename( defaultextension=".json", filetypes=[("JSON", "*.json"), ("All files", "*.*")], initialfile="iwt2-pvp-roguelike-sim.json", ) if not path: return Path(path).write_text(output, encoding="utf-8") self.status_var.set(f"Saved {path}") @staticmethod def _extract_json(text: str) -> str: start = text.find("{") end = text.rfind("}") if start < 0 or end < start: return "" candidate = text[start : end + 1] try: parsed = json.loads(candidate) except json.JSONDecodeError: return "" return json.dumps(parsed, indent=2) if __name__ == "__main__": SimGui().mainloop()