From e96c5a1e4a8a2b722eb9b24add84b3ad958c3907 Mon Sep 17 00:00:00 2001 From: WXP Date: Wed, 30 Sep 2026 21:35:14 -0400 Subject: [PATCH 1/7] 1 --- .gitattributes | 11 + .github/workflows/ci.yml | 41 ++ .gitignore | 4 + Game_Wuthering_Waves.py | 119 ++--- README.md | 39 +- _combo_commands.py | 69 ++- combo_engine.py | 133 +---- combo_engine_ui.py | 4 +- combos.example.json | 777 ++++++++++++++++++++++++++++ combos.json | 11 - docs/redesign/mockup-Main.dc.html | 594 ++++++++++++++++++++++ docs/redesign/mockup-canvas.json | 1 + documentation.md | 12 +- package.json | 9 + parser.py | 19 - persistence.py | 811 +++++++++++++++--------------- requirements.txt | 2 +- state_store.py | 9 +- states.py | 3 - static/app.js | 175 +++++-- static/characters.css | 291 +++++++++++ static/characters.html | 35 ++ static/characters.js | 480 ++++++++++++++++++ static/index.html | 84 +++- static/keys.css | 86 ++++ static/keys.html | 43 ++ static/keys.js | 60 +++ static/style.css | 148 ++++++ static/ww_moves.js | 107 ++++ static/ww_rotations.js | 113 +++++ tests/test_combo_commands.py | 125 +++++ tests/test_state_store.py | 30 ++ tests/test_ww_ender_policy.py | 204 ++++---- tests/test_ww_icons.py | 72 +++ tests/test_ww_library.py | 152 ++++++ tests/ww_moves.test.js | 60 +++ tests/ww_rotations.test.js | 38 ++ tools/ww_character_sync.py | 68 +-- ui_server.py | 125 ++++- ww_icons.py | 82 +++ ww_library.py | 396 +++++++++++++++ ww_sync.py | 83 +++ 42 files changed, 4783 insertions(+), 942 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 combos.example.json create mode 100644 docs/redesign/mockup-Main.dc.html create mode 100644 docs/redesign/mockup-canvas.json create mode 100644 package.json create mode 100644 static/characters.css create mode 100644 static/characters.html create mode 100644 static/characters.js create mode 100644 static/keys.css create mode 100644 static/keys.html create mode 100644 static/keys.js create mode 100644 static/ww_moves.js create mode 100644 static/ww_rotations.js create mode 100644 tests/test_combo_commands.py create mode 100644 tests/test_ww_icons.py create mode 100644 tests/test_ww_library.py create mode 100644 tests/ww_moves.test.js create mode 100644 tests/ww_rotations.test.js create mode 100644 ww_icons.py create mode 100644 ww_library.py create mode 100644 ww_sync.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2c0558e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Normalize text files to LF in the repo and working tree. +* text=auto eol=lf + +# Windows batch scripts need CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +*.png binary +*.ico binary +*.exe binary +*.dll binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..38ffb91 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: python -m pip install -r requirements.txt pytest + - name: Run tests + run: python -m pytest -q + - name: Run UI tests + run: node --test tests/ww_moves.test.js tests/ww_rotations.test.js + + build: + # Builds the Windows release folder so it can be downloaded from the run's + # Artifacts section without a local build. + needs: test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: python -m pip install -r requirements.txt -r requirements-build.txt + - name: Build + run: python -m PyInstaller --noconfirm --clean ComboTracker.spec + - uses: actions/upload-artifact@v4 + with: + name: ComboTracker-windows + path: dist/ComboTracker/ diff --git a/.gitignore b/.gitignore index 81c1a66..ac30faa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ combos.json.bak # Runtime profiler artifacts /logs/ +ww_library_cache/ + +# Your saved combos, teams and stats (combos.example.json seeds it on first run) +combos.json diff --git a/Game_Wuthering_Waves.py b/Game_Wuthering_Waves.py index 0ebc2c3..18f0a19 100644 --- a/Game_Wuthering_Waves.py +++ b/Game_Wuthering_Waves.py @@ -4,6 +4,15 @@ from typing import Any from uuid import uuid4 +# Defaults for a fresh install (no combos.json yet), tuned for Wuthering Waves: +# swapping characters (1/2/3), Resonance Skill (e), Liberation (r) and jumping end a combo +# unless they are the expected input. Values are ender cooldowns in ms. +WW_DEFAULT_COMBO_ENDERS: dict[str, int] = {"1": 1000, "2": 1000, "3": 1000, "e": 2000, "r": 2000, "space": 2000} +# Jumping during a hold (e.g. a charged heavy attack) does not drop the combo. +WW_DEFAULT_COMBO_ENDERS_SOFT: frozenset[str] = frozenset({"space"}) +# Inputs auto-transcribe records: interact/skill/echo/liberation, attack/dodge, swaps, jump, sprint. +WW_DEFAULT_TRANSCRIBE_KEYS = "f, e, q, r, lmb, rmb, 1, 2, 3, space, shift" + @dataclass class WutheringWavesGame: @@ -11,13 +20,10 @@ class WutheringWavesGame: Wuthering Waves specific state + helpers. This module exists to keep `ComboTrackerEngine` focused on core combo parsing/tracking, - while game-specific metadata (target game, WW teams/presets, WW combo->team mappings) + while game-specific metadata (WW teams/presets, WW combo->team mappings) lives in one place. """ - # Per-combo target game ("generic" | "wuthering_waves") - combo_target_game: dict[str, str] = field(default_factory=dict) - # Team presets (slot-based; characters are stored in ww_characters) # ww_teams: team_id -> { # "name": str, @@ -28,7 +34,7 @@ class WutheringWavesGame: ww_teams: dict[str, dict[str, Any]] = field(default_factory=dict) ww_active_team_id: str | None = None - # Per-combo assigned team (when target_game = wuthering_waves) + # Per-combo assigned team combo_ww_team: dict[str, str] = field(default_factory=dict) # Character library: name_lower -> { name, swap_image, lmb_image, ability_images: {e,q,r} } @@ -67,71 +73,29 @@ def can_ender_drop_combo( return False if not getattr(engine, "last_input_time", None) and getattr(engine, "current_index", 0) == 0: return False - if self.get_target_game(getattr(engine, "active_combo_name", None) or "") == "wuthering_waves": - key = (input_name or "").strip().lower() - if key in self.WW_CHARACTER_SLOTS and self.ww_active_character and key == self.ww_active_character: - return False + key = (input_name or "").strip().lower() + if key in self.WW_CHARACTER_SLOTS and self.ww_active_character and key == self.ww_active_character: + return False return True def on_accepted_key(self, engine: Any, input_name: str) -> None: - """When the correct key is 1/2/3 and game is WW, track active character for ender logic.""" - if self.get_target_game(getattr(engine, "active_combo_name", None) or "") != "wuthering_waves": - return + """When the correct key is 1/2/3, track the active character for ender logic.""" key = (input_name or "").strip().lower() if key in self.WW_CHARACTER_SLOTS: self.ww_active_character = key - def get_target_game(self, combo_name: str) -> str: - name = (combo_name or "").strip() - g = str(self.combo_target_game.get(name, "generic") or "generic").strip().lower() - return g if g in ("generic", "wuthering_waves") else "generic" - - def set_target_game(self, combo_name: str, target_game: str | None): - name = (combo_name or "").strip() - g = str(target_game or "").strip().lower() - if not name: - return - if g in ("generic", "wuthering_waves"): - self.combo_target_game[name] = g - else: - self.combo_target_game.pop(name, None) - - def apply_combo_team_assignment(self, combo_name: str, *, target_game: str, ww_team_id: str | None): - """ - Apply per-combo WW team assignment. - Expected to be called after `set_target_game()`. - """ + def apply_combo_team_assignment(self, combo_name: str, *, ww_team_id: str | None): + """Apply per-combo WW team assignment.""" name = (combo_name or "").strip() if not name: return - g = str(target_game or "generic").strip().lower() - if g == "wuthering_waves": - tid = str(ww_team_id or "").strip() - if tid and tid in self.ww_teams: - self.combo_ww_team[name] = tid - self.ww_active_team_id = tid - else: - self.combo_ww_team.pop(name, None) + tid = str(ww_team_id or "").strip() + if tid and tid in self.ww_teams: + self.combo_ww_team[name] = tid + self.ww_active_team_id = tid else: self.combo_ww_team.pop(name, None) - def rename_combo(self, old_name: str, new_name: str): - old = (old_name or "").strip() - new = (new_name or "").strip() - if not old or not new or old == new: - return - if old in self.combo_target_game and new not in self.combo_target_game: - self.combo_target_game[new] = self.combo_target_game.pop(old) - if old in self.combo_ww_team and new not in self.combo_ww_team: - self.combo_ww_team[new] = self.combo_ww_team.pop(old) - - def delete_combo(self, name: str): - cname = (name or "").strip() - if not cname: - return - self.combo_target_game.pop(cname, None) - self.combo_ww_team.pop(cname, None) - # ------------------------- # Character library helpers # ------------------------- @@ -166,12 +130,11 @@ def _resolved_team_images(self, team_data: dict[str, Any]) -> tuple[dict, dict, # Editor payload helpers # ------------------------- - def editor_payload(self, combo_name: str, target_game_override: str | None = None) -> dict[str, Any]: + def editor_payload(self, combo_name: str) -> dict[str, Any]: """ Build the WW-related section of the editor payload for the frontend. """ name = (combo_name or "").strip() - target_game = str(target_game_override).strip().lower() if target_game_override else self.get_target_game(name) # Build teams list with slot info ww_teams = [] @@ -189,11 +152,10 @@ def editor_payload(self, combo_name: str, target_game_override: str | None = Non # Selected team: active team > combo assignment > none sel_team_id = "" - if target_game == "wuthering_waves": - if self.ww_active_team_id and self.ww_active_team_id in self.ww_teams: - sel_team_id = self.ww_active_team_id - elif name and name in self.combo_ww_team and self.combo_ww_team[name] in self.ww_teams: - sel_team_id = self.combo_ww_team[name] + if self.ww_active_team_id and self.ww_active_team_id in self.ww_teams: + sel_team_id = self.ww_active_team_id + elif name and name in self.combo_ww_team and self.combo_ww_team[name] in self.ww_teams: + sel_team_id = self.combo_ww_team[name] team_name = "" team_slots = {"slot1": "", "slot2": "", "slot3": ""} @@ -231,7 +193,8 @@ def editor_payload(self, combo_name: str, target_game_override: str | None = Non }) return { - "target_game": target_game, + # The current UI still reads this; every combo is Wuthering Waves. + "target_game": "wuthering_waves", "ww_teams": ww_teams, "ww_team_id": sel_team_id, "ww_team_name": team_name, @@ -396,33 +359,13 @@ def delete_ww_team(engine, team_id: str) -> tuple[bool, str | None]: return True, None -def select_team_stateless(engine, team_id: str, target_game: str) -> None: +def select_team_stateless(engine, team_id: str) -> None: """Stateless team selection - doesn't persist, just updates UI.""" - tid = str(team_id or "").strip() - game = str(target_game or "generic").strip().lower() - - if game == "wuthering_waves": - engine.ww.set_active_ww_team(tid) - engine._send({"type": "combo_data", **engine.get_editor_payload(target_game_override=game)}) - engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) - return - - engine.ww.set_active_ww_team("") - engine._send({"type": "combo_data", **engine.get_editor_payload(target_game_override=game)}) + engine.ww.set_active_ww_team(str(team_id or "").strip()) + engine._send({"type": "combo_data", **engine.get_editor_payload()}) engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) -def update_target_game_stateless(engine, target_game: str) -> None: - """Stateless target game update - doesn't persist, just updates UI.""" - game = str(target_game or "generic").strip().lower() - if game not in ("generic", "wuthering_waves"): - game = "generic" - - payload = engine.get_editor_payload(target_game_override=game) - payload["target_game"] = game - engine._send({"type": "combo_data", **payload}) - - def save_ww_character_cmd( engine, *, diff --git a/README.md b/README.md index d3f7351..090e736 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -## ComboTracker +## ComboTracker for Wuthering Waves ![ComboTracker_AutoScroll](https://github.com/user-attachments/assets/b011d51d-d7c3-45e8-ae52-5e643f254e16) -A small local web UI + Python backend that listens to your keyboard/mouse and tracks whether you performed a defined “combo” correctly, including **wait** and **hold** timing steps. +A combo trainer built for **Wuthering Waves**. A small local web UI + Python backend listens to your keyboard/mouse and tracks whether you performed a rotation correctly, including **wait** and **hold** timing steps. + +Every combo is tied to a team of three Resonators: the timeline shows each character's portrait on swaps (`1`/`2`/`3`) and their own Basic Attack, Resonance Skill (`e`), Echo (`q`) and Liberation (`r`) icons. A fresh install comes with WuWa default combo enders (swaps, `e`, `r`, jump) and auto-transcribe keys. ![Combo Input](https://github.com/user-attachments/assets/30466399-8db6-474d-8508-b90143143ab7) @@ -11,6 +13,8 @@ For example, shorekeeper's Liberation is 2.9s in real time, but has a in game fr ### Features - **Practice combos**: see live status + a step timeline. +- **One-click characters**: in **Team & Characters**, type a name (e.g. `Phrolova`) and press **Add from wuthering.gg** to download their portrait and skill icons. **Refresh all icons** re-downloads icons for every saved character. Needs an internet connection. +- **Move names**: each step is labeled with the move it performs, like "Zani Basic 2", "Phoebe Intro" or "Tune Break", using your team slots. A leading `f` is the fight-start prompt; any later `f` is Tune Break. Toggle with **Move names** above the timeline. - **Wait + hold steps**: - `wait` = minimum delay gate (pressing later is OK). - `hold` = finger commitment (must hold long enough). @@ -56,11 +60,13 @@ python -m pip install -r requirements.txt ```bash cd ComboTracker -python ui_server.py +npm run serve ``` +(`npm run serve` just runs `python ui_server.py`, which also works without Node.) + Then open the UI: -- `http://localhost:8080` +- `http://localhost:8737` Notes: - The backend also runs a WebSocket server at `ws://localhost:8765`. @@ -78,8 +84,10 @@ You can package ComboTracker so others can run it without installing Python. python -m PyInstaller --noconfirm --clean ComboTracker.spec ``` 3. Release artifact: **`dist/ComboTracker/`**. Keep the entire folder together and launch **`dist/ComboTracker/ComboTracker.exe`**. Windows will prompt for **Administrator** approval once per launch; this matches elevated games so global input capture works in-game. -4. Open **`http://localhost:8080`** in your browser. A console window stays open with the server URL (close it or press Ctrl+C to stop). -5. **`combos.json`** (saved combos and settings) is written **next to the .exe**. Keep it in the release folder if you want saved data to move with the app; otherwise a new `combos.json` is created on first run. +4. Open **`http://localhost:8737`** in your browser. A console window stays open with the server URL (close it or press Ctrl+C to stop). +5. **`combos.json`** (saved combos and settings) is written **next to the .exe**. Keep it in the release folder if you want saved data to move with the app; otherwise a new `combos.json` is created on first run. Updating the app never touches it: the source download ships `combos.example.json`, which only seeds `combos.json` the first time. + +**Without a local build:** every push runs the **CI** workflow on GitHub Actions, which runs the tests and builds the Windows release. Download it from the workflow run's **Artifacts** section (`ComboTracker-windows`). **Publishing a release:** zip and ship the complete `dist/ComboTracker/` folder. The one-folder, non-UPX layout avoids the self-extracting executable pattern that commonly triggers antivirus heuristics. Build on a **normal (non-elevated)** terminal; PyInstaller may warn if you run it from an elevated shell. Code-signing the executable is recommended for public releases. @@ -94,7 +102,7 @@ You can show the **Combo Steps** timeline in OBS as a separate overlay (e.g. for https://github.com/user-attachments/assets/0639590a-1de7-43b0-9ec8-b7eda59a833e 1. In the main UI, open the **Combo Steps** section and click **Copy Overlay URL**. -2. In OBS, add a **Browser Source**, paste the URL (e.g. `http://localhost:8080/?view=timeline`), and set the width/height you want. The overlay stays in sync with the app via WebSocket. +2. In OBS, add a **Browser Source**, paste the URL (e.g. `http://localhost:8737/?view=timeline`), and set the width/height you want. The overlay stays in sync with the app via WebSocket, and reconnects on its own if you restart ComboTracker. 3. **Open in new window** is also available if you prefer OBS **Window Capture** instead of Browser Source. **Browser Source vs your browser:** The OBS Browser Source is a separate embedded browser. Toggles (Auto scroll, Images, Show fail count) are per-instance: use **Interact** on the Browser Source in OBS (right‑click the source → Interact) to open a window where you can click those controls for the overlay. Timeline content and progress sync for all clients; only the toggle state is local to each instance. @@ -106,6 +114,23 @@ For a walkthrough of common OBS Browser Source gotchas (separate instance, inter you can set custom CSS to zoom in `body {zoom : 150%;}` +## Live key overlay + +A built-in, NohBoard-style key display for streams and recordings: WASD, Shift, Space, 1/2/3, Q/E/R/F and both mouse buttons light up as you press them. The swap keys show your selected team's character names and portraits, and macro playback shows up too. + +- Open it with the keyboard button above **Combo Steps**, or add `http://localhost:8737/keys.html` as an OBS **Browser Source**. The background is transparent. +- It reconnects on its own if you restart ComboTracker. +- Only these keys are ever sent to the page, so other typing stays private. + +## Characters: moves and team rotations + +`http://localhost:8737/characters.html` (the book button above **Combo Steps**, or **Moves & rotations** under Team & Characters) lists every Wuthering Waves character with: + +- **Moves**: each skill's text and damage multipliers at any skill level, with the key it's on (LMB, E, R...). From the [encore.moe](https://encore.moe) API. +- **Team rotations**: the community's team setups for that character with DPS, author, video and calc sheet, from [AntoCrasher's calc compilation](https://docs.google.com/spreadsheets/d/1mdl9J08N-0_j-U2zNP5OTHGBKprwmJEmz_Iy4IOJfPk/edit). **Combo** opens the move-by-move rotation transcript, converts it to tracker inputs (swaps use the team's slot order; no timings), and **Save as combo** adds it to your combos with the video attached. + +Data is downloaded the first time you open a page and cached in `ww_library_cache/` next to `combos.json`, so it works offline afterwards. **Refresh data** downloads it again (cached copies older than a week refresh on their own). + --- ## Documentation diff --git a/_combo_commands.py b/_combo_commands.py index e7229f1..10b3dd6 100644 --- a/_combo_commands.py +++ b/_combo_commands.py @@ -10,6 +10,19 @@ from persistence import fresh_combo_stats +def per_combo_maps(engine) -> list[dict[str, Any]]: + """Every dict keyed by combo name. Rename and delete go through this list so none is missed.""" + return [ + engine.combo_stats, + engine.combo_expected_ms, + engine.combo_user_difficulty, + engine.combo_step_display_mode, + engine.combo_key_images, + engine.combo_demo_video, + engine.ww.combo_ww_team, + ] + + def apply_enders_from_text(engine, raw: str) -> tuple[bool, str | None]: raw = (raw or "").strip() if not raw: @@ -66,23 +79,25 @@ def save_or_update_combo( *, name: str, inputs: str, - enders: str, + enders: str | None, expected_time: str | None = None, user_difficulty: str | None = None, step_display_mode: str | None = None, key_images: Any | None = None, demo_video: str | None = None, - target_game: str | None = None, ww_team_id: str | None = None, + as_new: bool = False, ) -> tuple[bool, str | None]: name = (name or "").strip() keys_str = (inputs or "").strip() if not name or not keys_str: return False, "Please fill in Name and Inputs." - ok, err = apply_enders_from_text(engine, enders) - if not ok: - return False, err + # Enders are global settings; a caller that doesn't send them (the Characters page) leaves them alone. + if enders is not None: + ok, err = apply_enders_from_text(engine, enders) + if not ok: + return False, err expected_ms = None expected_raw = (expected_time or "").strip() @@ -105,24 +120,17 @@ def save_or_update_combo( if not input_list: return False, "Please provide at least one input." - old_name = engine.active_combo_name if engine.active_combo_name in engine.combos else None + # as_new: save a separate combo instead of editing (and renaming) the active one. + old_name = None if as_new else (engine.active_combo_name if engine.active_combo_name in engine.combos else None) + if name != old_name and name in engine.combos: + return False, f"A combo named '{name}' already exists. Pick another name." if old_name and name != old_name: - if old_name in engine.combo_stats and name not in engine.combo_stats: - engine.combo_stats[name] = engine.combo_stats.pop(old_name) + # Rename: carry everything stored under the old name (stats included) to the new one. + del engine.combos[old_name] engine.combos[name] = input_list - if old_name != name and old_name in engine.combos: - del engine.combos[old_name] - if old_name in engine.combo_expected_ms: - del engine.combo_expected_ms[old_name] - if old_name in engine.combo_user_difficulty: - del engine.combo_user_difficulty[old_name] - if old_name in engine.combo_step_display_mode and name not in engine.combo_step_display_mode: - engine.combo_step_display_mode[name] = engine.combo_step_display_mode.pop(old_name) - if old_name in engine.combo_key_images and name not in engine.combo_key_images: - engine.combo_key_images[name] = engine.combo_key_images.pop(old_name) - if old_name in getattr(engine, "combo_demo_video", {}) and name not in getattr(engine, "combo_demo_video", {}): - engine.combo_demo_video[name] = engine.combo_demo_video.pop(old_name) - engine.ww.rename_combo(old_name, name) + for mapping in per_combo_maps(engine): + if old_name in mapping: + mapping[name] = mapping.pop(old_name) else: # Same name or new combo: if steps changed, clear all history for this combo old_list = engine.combos.get(name) @@ -166,9 +174,7 @@ def save_or_update_combo( else: engine.combo_demo_video.pop(name, None) - g_raw = str(target_game or "").strip().lower() - engine.ww.set_target_game(name, g_raw) - engine.ww.apply_combo_team_assignment(name, target_game=engine.ww.get_target_game(name), ww_team_id=ww_team_id) + engine.ww.apply_combo_team_assignment(name, ww_team_id=ww_team_id) engine._ensure_combo_stats(name) engine.set_active_combo(name, emit=False) @@ -184,19 +190,8 @@ def delete_combo(engine, name: str) -> tuple[bool, str | None]: return False, "Select a combo to delete." del engine.combos[name] - if name in engine.combo_stats: - del engine.combo_stats[name] - if name in engine.combo_expected_ms: - del engine.combo_expected_ms[name] - if name in engine.combo_user_difficulty: - del engine.combo_user_difficulty[name] - if name in engine.combo_step_display_mode: - del engine.combo_step_display_mode[name] - if name in engine.combo_key_images: - del engine.combo_key_images[name] - if name in getattr(engine, "combo_demo_video", {}): - del engine.combo_demo_video[name] - engine.ww.delete_combo(name) + for mapping in per_combo_maps(engine): + mapping.pop(name, None) if engine.active_combo_name == name: engine.active_combo_name = None diff --git a/combo_engine.py b/combo_engine.py index 3747b68..e5d9b45 100644 --- a/combo_engine.py +++ b/combo_engine.py @@ -6,12 +6,14 @@ from typing import Any, Callable from Game_Wuthering_Waves import ( + WW_DEFAULT_COMBO_ENDERS, + WW_DEFAULT_COMBO_ENDERS_SOFT, + WW_DEFAULT_TRANSCRIBE_KEYS, WutheringWavesGame, set_active_ww_team, save_or_update_ww_team, delete_ww_team, select_team_stateless, - update_target_game_stateless, save_ww_character_cmd, delete_ww_character_cmd, update_ww_dash_cmd, @@ -98,7 +100,6 @@ def __init__( self.wait_in_progress = False self.wait_started_at = 0.0 - self.wait_until = 0.0 self.wait_required_ms: int | None = None self.currently_pressed: set[str] = set() @@ -107,8 +108,6 @@ def __init__( # step_index -> mark string (e.g. "ok", "early", "missed", "wrong") self.step_marks: dict[int, str] = {} # For soft waits: track if the *next expected input* was pressed during the wait window. - # wait_step_index -> set(inputs pressed too early for that gate) - self.wait_early_inputs: dict[int, set[str]] = {} # So we only send wait_begin once per group mandatory wait (UI animates progress). self.ui_adapter = UIAdapter() @@ -116,17 +115,16 @@ def __init__( # is already in currently_pressed, we start the hold immediately (no separate buffer state). # Combo enders: key -> cooldown_ms (0 = no cooldown; wrong press drops immediately) - self.combo_enders: dict[str, int] = {} + self.combo_enders: dict[str, int] = dict(WW_DEFAULT_COMBO_ENDERS) # Soft enders: keys that do not drop the combo when pressed during a hold step (~key:2s) - self.combo_enders_soft: set[str] = set() + self.combo_enders_soft: set[str] = set(WW_DEFAULT_COMBO_ENDERS_SOFT) # Auto-transcribe: comma-separated valid keys (persisted like combo_enders) - self.transcribe_valid_keys: str = "" + self.transcribe_valid_keys: str = WW_DEFAULT_TRANSCRIBE_KEYS # Key that begins a transcription when auto-transcribe is on (normalized token, e.g. f, space, lmb) self.transcribe_start_key: str = "f" # When enabled and transcribe_strip_wait_under_ms is non-empty, omit wait: below that many ms. self.transcribe_strip_wait_under_enabled: bool = False self.transcribe_strip_wait_under_ms: str = "0" - self.last_success_input: str | None = None # Per-ender cooldown: key -> monotonic time when that key's cooldown ends. # When the user correctly presses an ender key, we set cooldown for that key so # re-pressing it during a wait/hold doesn't drop the combo until cooldown expires. @@ -170,7 +168,9 @@ def __init__( # Persistence self.data_dir = self._get_data_dir() self.save_path = self.data_dir / "combos.json" - self.state_store: StateStore = state_store or JsonStateStore(self.save_path) + self.state_store: StateStore = state_store or JsonStateStore( + self.save_path, seed_path=self.data_dir / "combos.example.json" + ) # Load persisted state self.load_combos() @@ -181,14 +181,6 @@ def __init__( # These properties keep older code paths working while the WW logic is moved into # `Game_Wuthering_Waves.py`. - @property - def combo_target_game(self) -> dict[str, str]: - return self.ww.combo_target_game - - @combo_target_game.setter - def combo_target_game(self, value: dict[str, str]): - self.ww.combo_target_game = value - @property def ww_teams(self) -> dict[str, dict[str, Any]]: return self.ww.ww_teams @@ -250,12 +242,6 @@ def normalize_mouse(self, button) -> str: def split_inputs(self, keys_str: str): return input_normalization.split_inputs(keys_str or "") - def calc_min_combo_time_ms(self, steps: list[Any] | None) -> int: - """Fastest possible combo time in ms. Delegates to combo_analytics.""" - import combo_analytics - total = sum(combo_analytics._step_time_ms(s) for s in (steps or [])) - return max(0, int(total)) - def _format_ms(self, ms: int) -> str: return format_utils.format_ms(ms) @@ -274,42 +260,6 @@ def _start_keys_for_step(self, step: Any) -> set[str]: def _step_accepts_input(self, step: Any, input_name: str) -> bool: return step_introspection.step_accepts_input(step, input_name) - def _find_next_step_index_for_input(self, input_name: str, *, start_index: int) -> int | None: - """Look ahead for the next non-wait step that matches input_name.""" - input_name = (input_name or "").strip().lower() - if not input_name: - return None - try: - for j in range(max(0, int(start_index)), len(self.runtime_steps)): - s = self.runtime_steps[j] - if isinstance(s, WaitState): - continue - if self._step_accepts_input(s, input_name): - return j - except Exception: - return None - return None - - def _find_prev_step_index_for_input(self, input_name: str, *, end_index: int) -> int | None: - """Look backward for the most recent non-wait step that matches input_name.""" - input_name = (input_name or "").strip().lower() - if not input_name: - return None - try: - end = max(0, int(end_index)) - except Exception: - end = 0 - try: - for j in range(min(end, len(self.runtime_steps)) - 1, -1, -1): - s = self.runtime_steps[j] - if isinstance(s, WaitState): - continue - if self._step_accepts_input(s, input_name): - return j - except Exception: - return None - return None - def _mark_step(self, step_index: int, mark: str): """ Set a per-attempt mark for a step (for UI coloring). @@ -328,7 +278,6 @@ def _mark_step(self, step_index: int, mark: str): def _reset_attempt_marks(self): self.step_marks = {} - self.wait_early_inputs = {} def _next_non_wait_step_index(self, *, start_index: int) -> int | None: """Return the next step index >= start_index that is not a wait step.""" @@ -383,9 +332,6 @@ def save_combos(self) -> bool: def _ensure_combo_stats(self, name: str) -> None: stats_recording.ensure_combo_stats(self, name) - def _combo_avg_ms(self, name: str): - return stats_recording.combo_avg_ms(self, name) - def _format_percent(self, success: int, fail: int) -> str: return stats_recording.format_percent(success, fail) @@ -404,18 +350,6 @@ def min_time_text(self) -> str: def _parse_expected_time_ms(self, raw: str | None) -> int | None: return format_utils.parse_expected_time_ms(raw) - def _count_combo_actions(self, steps: list[Any] | None) -> tuple[int, int, int]: - """Returns (press_count, hold_count, total_actions). Delegates to combo_analytics.""" - import combo_analytics - if steps is None or steps is self.runtime_steps: - return combo_analytics.count_combo_actions(self) - press, hold = 0, 0 - for s in steps: - pp, hh = combo_analytics._count_step_actions(s) - press += pp - hold += hh - return press, hold, press + hold - def practical_apm(self) -> float | None: return combo_analytics.practical_apm(self) @@ -444,8 +378,8 @@ def user_difficulty_text(self) -> str: # UI state snapshots # ------------------------- - def get_editor_payload(self, target_game_override: str | None = None) -> dict[str, Any]: - return ui.get_editor_payload(self, target_game_override=target_game_override) + def get_editor_payload(self) -> dict[str, Any]: + return ui.get_editor_payload(self) def get_status(self) -> Status: return ui.get_status(self) @@ -500,14 +434,14 @@ def save_or_update_combo( *, name: str, inputs: str, - enders: str, + enders: str | None, expected_time: str | None = None, user_difficulty: str | None = None, step_display_mode: str | None = None, key_images: Any | None = None, demo_video: str | None = None, - target_game: str | None = None, ww_team_id: str | None = None, + as_new: bool = False, ) -> tuple[bool, str | None]: with self._lock: return combo_commands.save_or_update_combo( @@ -520,8 +454,8 @@ def save_or_update_combo( step_display_mode=step_display_mode, key_images=key_images, demo_video=demo_video, - target_game=target_game, ww_team_id=ww_team_id, + as_new=as_new, ) def delete_combo(self, name: str) -> tuple[bool, str | None]: @@ -584,13 +518,9 @@ def delete_ww_team(self, team_id: str) -> tuple[bool, str | None]: with self._lock: return delete_ww_team(self, team_id) - def select_team_stateless(self, team_id: str, target_game: str): - with self._lock: - select_team_stateless(self, team_id, target_game) - - def update_target_game_stateless(self, target_game: str): + def select_team_stateless(self, team_id: str): with self._lock: - update_target_game_stateless(self, target_game) + select_team_stateless(self, team_id) def new_combo(self): with self._lock: @@ -753,11 +683,7 @@ def set_active_combo(self, name: str, *, emit: bool = True): self._ensure_combo_stats(name) # Restore saved WW active team when selecting a combo - if self.ww.get_target_game(name) == "wuthering_waves": - saved_team = self.ww.combo_ww_team.get(name) - self.ww.ww_active_team_id = saved_team - else: - self.ww.ww_active_team_id = None + self.ww.ww_active_team_id = self.ww.combo_ww_team.get(name) self.reset_tracking() self.save_combos() @@ -800,7 +726,6 @@ def reset_tracking(self): self._attempt_hit_clock = None self._replay_active = False self.attempt_counter = 0 - self.last_success_input = None self._ender_cooldown_until.clear() self.ww.ww_active_character = None self._ui_last_success_combo = None @@ -910,7 +835,6 @@ def _reset_wait_state(self): self._send({"type": "wait_end"}) self.wait_in_progress = False self.wait_started_at = 0.0 - self.wait_until = 0.0 self.wait_required_ms = None def _reset_group_state(self): @@ -1205,7 +1129,6 @@ def _start_wait(self, required_ms: int, *, started_at: float): self.wait_in_progress = True self.wait_started_at = float(started_at) self.wait_required_ms = required_ms - self.wait_until = self.wait_started_at + (required_ms / 1000.0) # Tell the UI to animate a visible wait progress bar (similar to holds). # Mode may be soft|hard|mandatory (mandatory = animation lock; inputs ignored). try: @@ -1311,7 +1234,6 @@ def _complete_hold(self, now: float, *, auto: bool): else: self._record_step_timing(label, now) self.last_input_time = now - self.last_success_input = target_input self._start_ender_cooldown(target_input, now) self.ww.on_accepted_key(self, target_input) step.complete() @@ -1348,24 +1270,6 @@ def _complete_hold(self, now: float, *, auto: bool): self._reset_hold_state() return ok - def _record_fail_detail( - self, - *, - step_index: int, - expected: str, - actual: str, - reason: str, - elapsed_ms: float | None, - ) -> None: - stats_recording.record_fail_detail( - self, - step_index=step_index, - expected=expected, - actual=actual, - reason=reason, - elapsed_ms=elapsed_ms, - ) - def record_combo_success(self, completion_ms: float | int | None = None) -> None: stats_recording.record_combo_success(self, completion_ms) @@ -1669,7 +1573,6 @@ def _replay_accept_unlocked( label = f"{holder} (hold ≥ {req_s}, {held_ms:.0f}ms) [auto]" self.record_hit(label, step_ms, total_ms, at_time=now) self.last_input_time = now - self.last_success_input = holder self._start_ender_cooldown(holder, now) self.ww.on_accepted_key(self, holder) self._hold_max_held_ms = 0.0 @@ -1721,7 +1624,6 @@ def _replay_accept_unlocked( label = f"{holder} (hold ≥ {req_s}, {held_ms:.0f}ms) [auto]" self.record_hit(label, step_ms, total_ms, at_time=now) self.last_input_time = now - self.last_success_input = holder self._start_ender_cooldown(holder, now) self.ww.on_accepted_key(self, holder) step.complete() @@ -1922,7 +1824,6 @@ def _process_press_unlocked( else: self._record_step_timing(input_name, now) self.last_input_time = now - self.last_success_input = input_name self._start_ender_cooldown(input_name, now) self.ww.on_accepted_key(self, input_name) # If this press started a nested wait (e.g. press_wait inside a group/sequence), diff --git a/combo_engine_ui.py b/combo_engine_ui.py index 50d549e..859f41d 100644 --- a/combo_engine_ui.py +++ b/combo_engine_ui.py @@ -91,7 +91,7 @@ def user_difficulty_text(engine) -> str: return f"Your difficulty: {d:g} / 10" -def get_editor_payload(engine, target_game_override: str | None = None) -> dict[str, Any]: +def get_editor_payload(engine) -> dict[str, Any]: name = engine.active_combo_name or "" inputs = ", ".join(engine.active_combo_tokens) if engine.active_combo_tokens else "" @@ -138,7 +138,7 @@ def get_editor_payload(engine, target_game_override: str | None = None) -> dict[ if name: demo_video = (engine.combo_demo_video.get(name) or "").strip() - ww_payload = engine.ww.editor_payload(name, target_game_override=target_game_override) + ww_payload = engine.ww.editor_payload(name) return { "name": name, "inputs": inputs, diff --git a/combos.example.json b/combos.example.json new file mode 100644 index 0000000..3be9afa --- /dev/null +++ b/combos.example.json @@ -0,0 +1,777 @@ +{ + "version": 1, + "last_active_combo": "Hiyuki Lucilla Suisui", + "no_fail_mode": true, + "macro_start_key": "f", + "macro_stop_key": "esc", + "macro_spam_interval_ms": 30, + "combos": { + "Zani 1": [ + "f", + "wait:0.18s", + "e", + "wait:0.1s", + "lmb", + "wait:0.1s", + "2", + "hold(e, 0.35s)", + "wait(r, 3.65s)" + ], + "Zani 3": [ + "hold(e, 0.3s)", + "3", + "hold(lmb, 0.3s)", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "2", + "e", + "rmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "rmb", + "wait:0.1s", + "hold(lmb, 0.30s)", + "1", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "lmb", + "3", + "wait(f, 1s)", + "1", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "lmb", + "wait(3, 1.22s)", + "[wait(r, 2s), e, q]", + "wait(2, 1.63s)", + "1", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "r" + ], + "Zani 2": [ + "e", + "3", + "[wait(r, 2s), q, e]", + "2", + "lmb", + "wait:0.1s", + "rmb", + "wait:0.5s", + "lmb", + "q", + "rmb", + "hold(lmb, 0.30)", + "rmb", + "wait:0.5s", + "lmb", + "wait(1, 0.5)", + "[wait(e, 0.5), q]", + "wait(r, 3.2s)" + ], + "Zani Pheobe Rover Rotation": [ + "f", + "wait:0.11s", + "e", + "wait:0.2s", + "lmb", + "wait:0.1s", + "2", + "hold(e, 0.45s)", + "wait(r, 3.7s)", + "e", + "wait:0.05s", + "3", + "[wait(r, 2s), q, e]", + "2", + "[lmb, wait:0.1s, rmb, wait:0.5s, lmb, q, rmb, hold(lmb, 0.30), rmb, wait:0.5s, lmb]", + "wait(1, 0.5)", + "[wait(e, 0.5), q]", + "wait(r, 3.2s)", + "hold(e, 0.3s)", + "3", + "hold(lmb, 0.3s)", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "2", + "e", + "rmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "rmb", + "wait:0.1s", + "hold(lmb, 0.30s)", + "1", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "lmb", + "3", + "wait(f, 1s)", + "1", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "lmb", + "wait(3, 1.22s)", + "[wait(r, 2s), e, q]", + "wait(2, 1.63s)", + "1", + "lmb", + "wait:0.1s", + "lmb", + "wait:0.1s", + "r" + ], + "Agusta, Iuno, Shorekeeper Double intro": [ + "f", + "wait:0.15s", + "e", + "wait:0.15s", + "2", + "e", + "wait:0.15s", + "3", + "[q, e, wait(r, 2.9s)]", + "2", + "[e, wait:0.05s, q]", + "3", + "lmb", + "wait:0.30s", + "lmb", + "wait:0.30s", + "lmb", + "wait:0.20s", + "rmb", + "wait:0.1s", + "lmb", + "wait:0.15s", + "hold(lmb, 0.1s)", + "1", + "hold(lmb, 0.25s)", + "lmb", + "wait:0.3s", + "2", + "space", + "wait:0.15s", + "3", + "lmb", + "wait:0.3s", + "lmb", + "wait:0.1s", + "wait(1, 0.9s)", + "hold(lmb, 0.25s)", + "lmb", + "wait:0.3s", + "2", + "-e", + "wait(r, 3.9s)", + "lmb", + "wait:0.50s", + "lmb", + "wait:0.70s", + "lmb", + "wait:0.50s", + "-e", + "wait:0.20s", + "hold(lmb, 0.10s)", + "wait(1, 0.9s)", + "hold(lmb, 0.25s)", + "lmb", + "wait:0.3s", + "[{e, wait:0.80s, e, wait:1.0s, e, wait:0.2s}, wait(r, 1.6s)]", + "hold(r, 0.2s)", + "wait(lmb, 4.1s)" + ], + "Aemeath, Lynae, Mornye": [ + "f", + "wait:0.2s", + "lmb", + "wait:0.4s", + "lmb", + "wait:0.5s", + "lmb", + "wait:0.9s", + "lmb", + "wait:0.1s", + "-q", + "wait:0.05s", + "2", + "wait:0.15s", + "e", + "wait:0.05s", + "3", + "wait:0.25s", + "lmb", + "wait:0.8s", + "lmb", + "wait:0.2s", + "2", + "wait(r, 4.25s)", + "3", + "hold(lmb, 1.35s)", + "wait(r, 4.8s)", + "hold(lmb, 1.55s)", + "e", + "wait:1s", + "hold(lmb, 1.75s)", + "wait(2, 0.8s)", + "hold(lmb, 1.7s)", + "wait:0.47s", + "space", + "wait:0.66s", + "space", + "wait:0.65s", + "space", + "wait:0.81s", + "lmb", + "wait:0.77s", + "q", + "wait:0.2s", + "wait(1, 0.9s)", + "lmb", + "wait:0.5s", + "lmb", + "wait:0.85s", + "lmb", + "wait:0.77s", + "wait(f, 1.75s)", + "wait(r, 4.2s)", + "wait(e, 2.8s)", + "lmb", + "wait:0.5s", + "lmb", + "wait:0.9s", + "lmb", + "wait:0.5s", + "lmb", + "wait:0.5s", + "wait(e, 3s)", + "hold(lmb, 1.1s)", + "wait(r, 4.2s)" + ], + "Agusta, Iuno, Shorekeeper Double vs monkey": [ + "f", + "wait:0.2s", + "e", + "wait:0.15s", + "2", + "e", + "wait:0.15s", + "3", + "[q, e, wait(r, 3s)]", + "lmb", + "wait:0.30s", + "lmb", + "wait:0.20s", + "rmb", + "wait:0.20s", + "lmb", + "2", + "[e, wait:0.05s, q]", + "3", + "lmb", + "wait:0.30s", + "lmb", + "wait:0.20s", + "rmb", + "wait:0.20s", + "lmb", + "1", + "hold(lmb, 0.25s)", + "lmb", + "wait:0.3s", + "2", + "space", + "wait:0.15s", + "3", + "lmb", + "wait:0.20s", + "wait(1, 0.9s)", + "hold(lmb, 0.25s)", + "lmb", + "wait:0.3s", + "2", + "-e", + "wait(r, 3.9s)", + "lmb", + "wait:0.50s", + "lmb", + "wait:0.70s", + "lmb", + "wait:0.50s", + "-e", + "wait:0.20s", + "hold(lmb, 0.10s)", + "wait(1, 0.9s)", + "hold(lmb, 0.25s)", + "lmb", + "wait:0.3s", + "[{e, wait:0.80s, e, wait:1.0s, e, wait:0.2s}, wait(r, 1.6s)]", + "hold(r, 0.2s)", + "wait(lmb, 4.1s)" + ], + "test2": [ + "f", + "wait:0.1s", + "spam(lmb, 2.2s)", + "rmb", + "wait:0.1s", + "spam(lmb, 1.8s)", + "space", + "wait:0.1s", + "hold(e, 0.9s)", + "spam(lmb, 2.8s)", + "rmb", + "wait:0.1s", + "spam(lmb, 1.9s)", + "space", + "wait:0.1s", + "hold(e, 1s)", + "lmb", + "wait:0.17s", + "lmb", + "wait:0.177s", + "lmb", + "wait:0.155s", + "lmb", + "wait:0.153s", + "lmb", + "wait:0.16s", + "lmb", + "wait:0.167s", + "lmb", + "wait:0.153s", + "lmb", + "wait:0.14s", + "hold(lmb, 1.2s)", + "hold(r, 4s)" + ], + "Hiyuki Lucilla Suisui": [ + "f", + "wait:0.15s", + "hold(lmb, 2.3s)", + "e", + "hold(lmb, 2.8s)", + "e", + "hold(lmb, 1s)", + "1", + "hold(lmb, 1.4s)", + "e", + "wait:0.1s", + "3", + "wait(r, 4.4s)", + "wait(2, 0.1s)", + "hold(e, 1.5s)", + "q", + "wait:1.2s", + "wait(r, 4.4s)", + "hold(lmb, 0.55s)", + "1", + "hold(lmb, 1.05s)", + "2", + "hold(lmb, 3.5s)", + "1", + "hold(lmb, 2.9s)", + "wait(r, 3.95s)", + "lmb", + "wait:0.6s", + "lmb", + "wait:0.6s", + "lmb", + "wait:0.1s", + "rmb", + "wait:0.2s", + "lmb", + "wait:0.7s", + "lmb", + "wait:0.6s", + "lmb", + "wait:0.1s", + "space", + "hold(e, 0.8s)", + "spam(lmb, 1.2s)", + "wait(f, 1.75s)", + "lmb", + "wait:0.2s", + "rmb", + "wait:0.2s", + "lmb", + "wait:0.7s", + "lmb", + "wait:0.7s", + "lmb", + "wait:0.6s", + "space", + "wait:0.1s", + "hold(e, 0.8s)", + "spam(lmb, 1.2s)", + "hold(lmb, 2.8s)", + "wait(r, 3.95s)", + "q", + "e", + "hold(lmb, 0.5s)", + "wait(3, 0.5s)", + "hold(lmb, 3.5s)" + ] + }, + "combo_enders": { + "1": 1000, + "2": 1000, + "3": 1000, + "e": 2000, + "r": 2000, + "space": 2000 + }, + "combo_enders_soft": [ + "space" + ], + "transcribe_valid_keys": "f, e, q, r, lmb, rmb, 1, 2, 3, space, shift", + "transcribe_start_key": "f", + "transcribe_strip_wait_under_enabled": false, + "transcribe_strip_wait_under_ms": "0", + "combo_stats": { + "Zani 1": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "Zani 3": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "Zani 2": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "Zani Pheobe Rover Rotation": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "Agusta, Iuno, Shorekeeper Double intro": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "Aemeath, Lynae, Mornye": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "Agusta, Iuno, Shorekeeper Double vs monkey": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "test2": { + "success": 1, + "fail": 0, + "best_ms": 17585, + "total_success_ms": 17585, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + }, + "Hiyuki Lucilla Suisui": { + "success": 0, + "fail": 0, + "best_ms": null, + "total_success_ms": 0, + "fail_by_step": {}, + "fail_by_expected": {}, + "fail_by_reason": {}, + "fail_events": [] + } + }, + "combo_expected_ms": { + "Zani 1": 5000, + "Zani 3": 19000, + "Zani 2": 9000, + "Zani Pheobe Rover Rotation": 30000, + "Agusta, Iuno, Shorekeeper Double intro": 24000, + "Aemeath, Lynae, Mornye": 42500, + "Agusta, Iuno, Shorekeeper Double vs monkey": 21000 + }, + "combo_user_difficulty": { + "Zani 1": 8.0, + "Agusta, Iuno, Shorekeeper Double intro": 7.5, + "Aemeath, Lynae, Mornye": 7.5, + "Agusta, Iuno, Shorekeeper Double vs monkey": 8.0 + }, + "combo_step_display_mode": { + "Zani 3": "images", + "Zani 2": "images", + "Zani 1": "images", + "Zani Pheobe Rover Rotation": "images", + "Agusta, Iuno, Shorekeeper Double intro": "images", + "Aemeath, Lynae, Mornye": "images", + "Agusta, Iuno, Shorekeeper Double vs monkey": "images", + "test2": "images", + "Hiyuki Lucilla Suisui": "images" + }, + "combo_key_images": {}, + "combo_demo_video": { + "Aemeath, Lynae, Mornye": "https://youtu.be/goTBFZBsBTo" + }, + "combo_target_game": { + "Zani 1": "wuthering_waves", + "Zani 2": "wuthering_waves", + "Zani Pheobe Rover Rotation": "wuthering_waves", + "Zani 3": "wuthering_waves", + "Agusta, Iuno, Shorekeeper Double intro": "wuthering_waves", + "Aemeath, Lynae, Mornye": "wuthering_waves", + "Agusta, Iuno, Shorekeeper Double vs monkey": "wuthering_waves", + "test2": "wuthering_waves", + "Hiyuki Lucilla Suisui": "wuthering_waves" + }, + "ww_characters": { + "aemeath": { + "name": "Aemeath", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_53_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34025_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAimisiB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAimisiC1.png" + } + }, + "lynae": { + "name": "Lynae", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_60_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorGun.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34021_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconLinNaiB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconLinNaiC1.png" + } + }, + "mornye": { + "name": "Mornye", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_61_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorSword.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34022_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconMoNingB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconMoNingC1.png" + } + }, + "agusta": { + "name": "Agusta", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_51_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorSword.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34017_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAogusitaB3.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAogusitaC2.png" + } + }, + "iuno": { + "name": "Iuno", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_48_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorFist.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34018_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconYounuoB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconYounuoC1.png" + } + }, + "shorekeeper": { + "name": "Shorekeeper", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_28_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_350_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconShouanrenB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconShouanrenC1.png" + } + }, + "hiyuki": { + "name": "Hiyuki", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_67_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34027_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeixueB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeixueC1.png" + } + }, + "chisa": { + "name": "Chisa", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_57_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorSword.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconQianxiaoC1.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconQianxiaoB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconQianxiaoC1.png" + } + }, + "zani": { + "name": "Zani", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_38_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorFist.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_32033_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZanniB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZanniC1.png" + } + }, + "pheobe": { + "name": "Pheobe", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_45_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_995_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeibiB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeibiC1.png" + } + }, + "spectro_rover": { + "name": "Spectro_Rover", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_5.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_350_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZhujueB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZhujueC1.png" + } + }, + "lucilla": { + "name": "Lucilla", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_66_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34027_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_SkillIconLuoselaB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_SkillIconLuoselaC1.png" + } + }, + "suisui": { + "name": "Suisui", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_71_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34031_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconSuisuiB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconSuisuiC1.png" + } + }, + "yangyang xuanling": { + "name": "Yangyang Xuanling", + "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_70_UI.png", + "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", + "ability_images": { + "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34031_UI.png", + "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconXuanLingB1.png", + "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconXuanLingC1.png" + } + } + }, + "ww_dash_image": "\ud83d\udca8", + "ww_teams": { + "3fab25fc2a": { + "name": "Aemeath Lynae Mornye", + "slot1": "aemeath", + "slot2": "lynae", + "slot3": "mornye" + }, + "65d147f85c": { + "name": "Lynae Aemeth Mornye", + "slot1": "lynae", + "slot2": "aemeath", + "slot3": "mornye" + }, + "cc9555e2d9": { + "name": "Zani Phobe Rover", + "slot1": "zani", + "slot2": "pheobe", + "slot3": "spectro_rover" + }, + "a8e3fa33e9": { + "name": "Agusta Iuno Shorekeeper", + "slot1": "agusta", + "slot2": "iuno", + "slot3": "shorekeeper" + }, + "8f6a67b57d": { + "name": "Hiyuki Lucilla Suisui", + "slot1": "hiyuki", + "slot2": "lucilla", + "slot3": "suisui" + }, + "18d2a3510a": { + "name": "Xuanling, Chisa, SuiSui", + "slot1": "yangyang xuanling", + "slot2": "chisa", + "slot3": "suisui" + } + }, + "ww_active_team_id": "8f6a67b57d", + "combo_ww_team": { + "Aemeath, Lynae, Mornye": "3fab25fc2a", + "Agusta, Iuno, Shorekeeper Double intro": "a8e3fa33e9", + "Agusta, Iuno, Shorekeeper Double vs monkey": "a8e3fa33e9", + "Zani 1": "cc9555e2d9", + "Zani 2": "cc9555e2d9", + "Zani 3": "cc9555e2d9", + "Zani Pheobe Rover Rotation": "cc9555e2d9", + "test2": "8f6a67b57d", + "Hiyuki Lucilla Suisui": "8f6a67b57d" + } +} \ No newline at end of file diff --git a/combos.json b/combos.json index 3be9afa..d1f3049 100644 --- a/combos.json +++ b/combos.json @@ -570,17 +570,6 @@ "combo_demo_video": { "Aemeath, Lynae, Mornye": "https://youtu.be/goTBFZBsBTo" }, - "combo_target_game": { - "Zani 1": "wuthering_waves", - "Zani 2": "wuthering_waves", - "Zani Pheobe Rover Rotation": "wuthering_waves", - "Zani 3": "wuthering_waves", - "Agusta, Iuno, Shorekeeper Double intro": "wuthering_waves", - "Aemeath, Lynae, Mornye": "wuthering_waves", - "Agusta, Iuno, Shorekeeper Double vs monkey": "wuthering_waves", - "test2": "wuthering_waves", - "Hiyuki Lucilla Suisui": "wuthering_waves" - }, "ww_characters": { "aemeath": { "name": "Aemeath", diff --git a/docs/redesign/mockup-Main.dc.html b/docs/redesign/mockup-Main.dc.html new file mode 100644 index 0000000..58ddcf2 --- /dev/null +++ b/docs/redesign/mockup-Main.dc.html @@ -0,0 +1,594 @@ + + + + +Combo Tracker redesign + + + + + + + + + + +
+ + + + + +
+ + +
+
+ {{overline}} +

{{pageTitle}}

+
+
+ + +
+ + + +
+
+
+
+ + + +
+ +
+ +
+ {{s.label}} + {{s.value}} +
+
+
+ +
+
+

Combo Steps

+
+ + + +
+
+ +

{{promptTitle}}

+ +
+ +
+ {{s.corner}} + {{s.key}} + {{s.time}} + {{s.label}} +
+
+
+ +
+ + +
+
+ +
+
+

Last attempt

+ Step times in ms +
+ +
+ +
+ {{l.key}} + {{l.ms}} +
+
+
+
+ +

No attempts yet. Press F in game to start, or use Simulate next input to see how a run looks.

+
+
+
+
+ + + +
+
+
+ + + +
+ + + +
+ +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ Reads as + + {{s.key}}{{s.label}} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+

Record or replay

+
+ + +
+

Recording starts when you press F and stops on Esc. Macro replay starts on F8 and stops on F9 or Esc. Keys and delays are set in Settings.

+
+
+
+
+ + + +
+
+
+ +
+
+

{{t.name}}

+ {{t.combos}} +
+
+ +
+ {{m.slot}} + {{m.initials}} + {{m.name}} +
+
+
+
+
+
+
+
+ + + +
+

The Characters page you already have (moves from encore.moe, team rotations from AntoCrasher's sheet) moves in here as a page of the app, restyled to match.

+
+ + + +
+
+
+ + + +
+
+ ComboSuccess / failBestAverage +
+ +
+ {{c.name}}0 / 0—— +
+
+

Your saved combos have no attempts logged yet, so every row is empty.

+
+
+ + + +
+
+

Recording

+
+
+
+
+
+

Macro replay

+
+
+
+
+
+

Stream overlays

+

Add these as OBS Browser Sources. They keep their current look.

+
localhost:8080/?view=timeline
+
localhost:8080/keys.html
+
+
+

Data

+

Combos, teams and stats live in combos.json next to the app.

+
+ + + +
+
+
+
+
+ + + + + + +
{{toast}}
+
+
+
+ + + diff --git a/docs/redesign/mockup-canvas.json b/docs/redesign/mockup-canvas.json new file mode 100644 index 0000000..1f7c8c7 --- /dev/null +++ b/docs/redesign/mockup-canvas.json @@ -0,0 +1 @@ +{"v":3,"createdOnFiles":{"v":1,"at":"2026-09-30T22:07:54Z"},"title":"Combo Tracker Redesign","launch":{"view":"focused","file":"Main.dc.html"},"pages":[],"boards":{"Main.dc.html":{"x":0,"y":0,"w":1320,"h":860,"title":"Combo Tracker (BetterGhub style)","expand":"fill","is_interactive":true}},"order":["Main.dc.html"],"notes":{},"designSystems":[]} diff --git a/documentation.md b/documentation.md index 631275a..f992e1a 100644 --- a/documentation.md +++ b/documentation.md @@ -171,7 +171,7 @@ Example: - **During the attempt**: pressing an ender key drops the combo only if that key is **off cooldown**. - **Key repeat (held key) does not drop**: only a **new key-down** can end the combo. If you keep a key held (e.g. hold `r` through a wait), repeat key events from the OS are ignored for ender logic—so the combo will not drop until you release and press that key again (a genuine new press). -Data is stored in `combos.json`. +Data is stored in `combos.json`. On first run it is seeded from `combos.example.json` when that file is present; updates never overwrite `combos.json`. --- @@ -204,7 +204,7 @@ This is intentionally simple so it’s easy to tune. ## Troubleshooting -### I get a 404 when opening `http://localhost:8080/` +### I get a 404 when opening `http://localhost:8737/` Make sure you’re running `ui_server.py` from `ComboTracker/`, or just run it normally—recent versions serve `static/` using an absolute path so it works regardless of working directory. @@ -247,7 +247,7 @@ misclassified inputs without re-recording the combo. ComboTracker is a small local web UI + Python backend. Roughly: - **UI server + input hooks**: `ui_server.py` - - Runs HTTP (`localhost:8080`) serving `static/` + - Runs HTTP (`localhost:8737`) serving `static/` - Runs WebSocket (`localhost:8765`) - Starts global keyboard/mouse hooks via `pynput` - Calls into `ComboTrackerEngine` @@ -270,10 +270,12 @@ ComboTracker is a small local web UI + Python backend. Roughly: - Tracks signatures and emits wait animation events only when needed - **Persistence (schema + load/save/migrations)**: `persistence.py` - - Owns JSON schema compatibility and sanitization + - Owns JSON schema compatibility and sanitization; `load_engine_state` runs one small loader per section (combos, enders, stats, teams, legacy migrations, ...) - **Commands (internal engine-only helpers)**: `_combo_commands.py` - Apply/save/delete/new/clear commands that mutate engine data (called under the engine lock) + - `per_combo_maps()` lists every dict keyed by combo name; rename and delete go through it. Add new per-combo settings there. + - Saving refuses a name another combo already uses; `as_new` saves a separate combo instead of editing the active one (used by the Characters page) - **Utilities extracted for maintainability**: - `step_introspection.py`: step labeling and key-introspection helpers @@ -286,7 +288,7 @@ ComboTracker is a small local web UI + Python backend. Roughly: - Preserves raw evidence through `profiling/transcription_log.py` - **Game-specific state / helpers**: `Game_Wuthering_Waves.py` - - Stores WW-specific metadata (teams, target game, active character slot) + - Stores WW-specific metadata (teams, per-combo team, active character slot) - Provides WW ender policy used by the engine ### Macro timing profiler diff --git a/package.json b/package.json new file mode 100644 index 0000000..986d517 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "combotracker", + "private": true, + "scripts": { + "serve": "python ui_server.py", + "test": "python -m pytest -q && node --test tests/ww_moves.test.js tests/ww_rotations.test.js", + "test:ui": "node --test tests/ww_moves.test.js tests/ww_rotations.test.js" + } +} diff --git a/parser.py b/parser.py index e852c54..75d4e0e 100644 --- a/parser.py +++ b/parser.py @@ -619,22 +619,3 @@ def runtime_source_token_indices_from_tokens(tokens: list[str]) -> list[list[int return source_map -def steps_from_tokens(tokens: list[str]) -> list[dict[str, Any]]: - """ - Parse tokens and build flat list of step dicts. - Expands composite_steps (e.g. wait(r,1.5)) inline. - Mirrors set_active_combo step-building logic. - """ - steps: list[dict[str, Any]] = [] - for t in tokens: - node = parse_step(t) - if node is None: - continue - d = build_state(node) - if d.get("composite_steps") is not None: - for sub in d.get("composite_steps") or []: - if isinstance(sub, dict) and sub: - steps.append(sub) - else: - steps.append(d) - return steps diff --git a/persistence.py b/persistence.py index 954a7f7..7fb33da 100644 --- a/persistence.py +++ b/persistence.py @@ -42,6 +42,410 @@ def _clean_counter_dict(obj: Any, *, key_norm) -> dict[str, int]: return out +def _load_combos(engine, data: dict[str, Any]) -> None: + """Combo name -> list of input tokens.""" + combos = data.get("combos", {}) + if isinstance(combos, dict): + sanitized: dict[str, list[str]] = {} + for name, seq in combos.items(): + if not isinstance(name, str) or not isinstance(seq, list): + continue + sanitized[name] = [str(x).strip().lower() for x in seq if str(x).strip()] + engine.combos = sanitized + + +def _load_enders(engine, data: dict[str, Any]) -> None: + """Combo enders and soft enders (missing keys keep the engine's Wuthering Waves defaults).""" + enders = data.get("combo_enders", dict(engine.combo_enders)) + parsed: dict[str, int] = {} + if isinstance(enders, dict): + for k, v in enders.items(): + key = str(k).strip().lower() + if not key: + continue + ms = _as_int(v, 0) + parsed[key] = max(0, ms) + elif isinstance(enders, list): + for x in enders: + key = str(x).strip().lower() + if key: + parsed[key] = 0 + engine.combo_enders = parsed + + # Soft enders (~key:2s): do not drop combo when pressed during hold + soft = data.get("combo_enders_soft", sorted(engine.combo_enders_soft)) + if isinstance(soft, list): + engine.combo_enders_soft = {str(x).strip().lower() for x in soft if str(x).strip()} + else: + engine.combo_enders_soft = set() + + +def _load_transcribe_settings(engine, data: dict[str, Any]) -> None: + """Recording (transcribe) settings.""" + tvk = data.get("transcribe_valid_keys") + if isinstance(tvk, str): + engine.transcribe_valid_keys = tvk.strip() + else: + engine.transcribe_valid_keys = getattr(engine, "transcribe_valid_keys", "") or "" + + tsk = data.get("transcribe_start_key") + if isinstance(tsk, str) and tsk.strip(): + engine.transcribe_start_key = tsk.strip().lower() + else: + engine.transcribe_start_key = getattr(engine, "transcribe_start_key", "f") or "f" + + tst = data.get("transcribe_strip_wait_under_enabled") + if isinstance(tst, bool): + engine.transcribe_strip_wait_under_enabled = tst + elif tst is None: + engine.transcribe_strip_wait_under_enabled = False + else: + engine.transcribe_strip_wait_under_enabled = bool(tst) + + tsms = data.get("transcribe_strip_wait_under_ms") + if isinstance(tsms, str): + engine.transcribe_strip_wait_under_ms = tsms.strip() + elif isinstance(tsms, (int, float)): + engine.transcribe_strip_wait_under_ms = str(int(tsms)) + elif tsms is None: + engine.transcribe_strip_wait_under_ms = "0" + else: + engine.transcribe_strip_wait_under_ms = str(tsms).strip() + + +def _load_stats(engine, data: dict[str, Any]) -> None: + """Per-combo stats, keeping only recent fail events to cap file growth.""" + stats = data.get("combo_stats", {}) + if isinstance(stats, dict): + cleaned: dict[str, dict[str, Any]] = {} + for k, v in stats.items(): + name = str(k).strip() + if not name or not isinstance(v, dict): + continue + s = max(0, _as_int(v.get("success", 0), 0)) + f = max(0, _as_int(v.get("fail", 0), 0)) + best_raw = v.get("best_ms") + best_ms = _as_int(best_raw, 0) if best_raw is not None else None + total_success_ms = max(0, _as_int(v.get("total_success_ms", 0), 0)) + + fail_by_step = _clean_counter_dict(v.get("fail_by_step", {}), key_norm=lambda kk: str(kk).strip()) + fail_by_expected = _clean_counter_dict( + v.get("fail_by_expected", {}), + key_norm=lambda kk: str(kk).strip().lower(), + ) + fail_by_reason = _clean_counter_dict( + v.get("fail_by_reason", {}), + key_norm=lambda kk: str(kk).strip().lower(), + ) + + # Keep only recent fail events to cap file growth. + fail_events = v.get("fail_events", []) + fes: list[dict[str, Any]] = [] + if isinstance(fail_events, list): + for ev in fail_events[-100:]: + if not isinstance(ev, dict): + continue + fes.append( + { + "ts": int(ev.get("ts", 0) or 0), + "attempt": max(0, _as_int(ev.get("attempt", 0), 0)), + "step_index": max(0, _as_int(ev.get("step_index", 0), 0)), + "expected": str(ev.get("expected", "") or ""), + "actual": str(ev.get("actual", "") or ""), + "reason": str(ev.get("reason", "") or ""), + "elapsed_ms": (_as_int(ev.get("elapsed_ms"), 0) if ev.get("elapsed_ms") is not None else None), + } + ) + + cleaned[name] = { + "success": s, + "fail": f, + "best_ms": best_ms, + "total_success_ms": total_success_ms, + "fail_by_step": fail_by_step, + "fail_by_expected": fail_by_expected, + "fail_by_reason": fail_by_reason, + "fail_events": fes, + } + engine.combo_stats = cleaned + + +def _load_combo_options(engine, data: dict[str, Any]) -> None: + """Optional per-combo settings: expected time, difficulty, display mode, key images, demo video.""" + exp = data.get("combo_expected_ms", {}) + expected_ms: dict[str, int] = {} + if isinstance(exp, dict): + for k, v in exp.items(): + name = str(k).strip() + if not name: + continue + ms = _as_int(v, -1) + if ms > 0: + expected_ms[name] = ms + engine.combo_expected_ms = expected_ms + + # Optional: per-combo user difficulty (0..10) + ud = data.get("combo_user_difficulty", {}) + user_diff: dict[str, float] = {} + if isinstance(ud, dict): + for k, v in ud.items(): + name = str(k).strip() + if not name: + continue + try: + d = float(v) + except Exception: + continue + if 0.0 <= d <= 10.0: + user_diff[name] = d + engine.combo_user_difficulty = user_diff + + # Optional: per-combo step display mode ("icons" | "images") + dm = data.get("combo_step_display_mode", {}) + display_mode: dict[str, str] = {} + if isinstance(dm, dict): + for k, v in dm.items(): + name = str(k).strip() + if not name: + continue + mode = str(v or "").strip().lower() + if mode in ("icons", "images"): + display_mode[name] = mode + engine.combo_step_display_mode = display_mode + + # Optional: per-combo key images mapping + ki = data.get("combo_key_images", {}) + key_images: dict[str, dict[str, str]] = {} + if isinstance(ki, dict): + for k, v in ki.items(): + name = str(k).strip() + if not name or not isinstance(v, dict): + continue + m: dict[str, str] = {} + for kk, vv in v.items(): + key = str(kk).strip().lower() + url = str(vv).strip() + if not key or not url: + continue + m[key] = url + if m: + key_images[name] = m + engine.combo_key_images = key_images + + # Per-combo demo video URL (e.g. YouTube) + dv = data.get("combo_demo_video", {}) + demo_video: dict[str, str] = {} + if isinstance(dv, dict): + for k, v in dv.items(): + name = str(k).strip() + url = str(v or "").strip() + if name and url: + demo_video[name] = url + engine.combo_demo_video = demo_video + + +def _load_ww_characters(engine, data: dict[str, Any]) -> None: + """WW character library and the global dash image.""" + chars_raw = data.get("ww_characters", {}) + ww_characters: dict[str, Any] = {} + if isinstance(chars_raw, dict): + for ckey, cv in chars_raw.items(): + key = str(ckey or "").strip().lower() + if not key or not isinstance(cv, dict): + continue + ai: dict[str, str] = {} + ai_raw = cv.get("ability_images", {}) + if isinstance(ai_raw, dict): + for a, v in ai_raw.items(): + akey = str(a or "").strip().lower() + if akey in ("e", "q", "r"): + url = str(v or "").strip() + if url: + ai[akey] = url + ww_characters[key] = { + "name": str(cv.get("name", "") or key), + "swap_image": str(cv.get("swap_image", "") or "").strip(), + "lmb_image": str(cv.get("lmb_image", "") or "").strip(), + "ability_images": ai, + } + engine.ww.ww_characters = ww_characters + + # Global dash image + engine.ww.ww_dash_image = str(data.get("ww_dash_image", "") or "").strip() + + +def _load_ww_teams(engine, data: dict[str, Any]) -> None: + """WW teams (migrating the old embedded-image format) and per-combo team assignments.""" + teams = data.get("ww_teams", {}) + ww_teams: dict[str, dict[str, Any]] = {} + if isinstance(teams, dict): + for tid, tv in teams.items(): + team_id = str(tid).strip() + if not team_id or not isinstance(tv, dict): + continue + team_name = str(tv.get("name", "") or "").strip() or "Team" + + if "slot1" in tv or "slot2" in tv or "slot3" in tv: + # New format + ww_teams[team_id] = { + "name": team_name, + "slot1": str(tv.get("slot1", "") or "").strip().lower(), + "slot2": str(tv.get("slot2", "") or "").strip().lower(), + "slot3": str(tv.get("slot3", "") or "").strip().lower(), + } + else: + # Old format: migrate embedded swap/lmb/ability per slot into characters + dash_old = str(tv.get("dash_image", "") or "").strip() + if dash_old and not engine.ww.ww_dash_image: + engine.ww.ww_dash_image = dash_old + swap_old = tv.get("swap_images") or {} + lmb_old = tv.get("lmb_images") or {} + abil_old = tv.get("ability_images") or {} + slot_chars: dict[str, str] = {} + for slot in ("1", "2", "3"): + has_swap = isinstance(swap_old, dict) and str(swap_old.get(slot, "") or "").strip() + has_lmb = isinstance(lmb_old, dict) and str(lmb_old.get(slot, "") or "").strip() + has_abil = ( + isinstance(abil_old, dict) + and isinstance(abil_old.get(slot), dict) + and any(str(v or "").strip() for v in abil_old[slot].values()) + ) + if has_swap or has_lmb or has_abil: + char_display = f"{team_name} {slot}" + char_key = char_display.lower() + suffix = 0 + while char_key in engine.ww.ww_characters: + suffix += 1 + char_key = f"{char_display.lower()}_{suffix}" + char_display = f"{team_name} {slot}_{suffix}" + slot_ai: dict[str, str] = {} + if isinstance(abil_old, dict) and isinstance(abil_old.get(slot), dict): + for a, v in abil_old[slot].items(): + akey = str(a or "").strip().lower() + if akey in ("e", "q", "r"): + url = str(v or "").strip() + if url: + slot_ai[akey] = url + engine.ww.ww_characters[char_key] = { + "name": char_display, + "swap_image": str(swap_old.get(slot, "") or "").strip() if isinstance(swap_old, dict) else "", + "lmb_image": str(lmb_old.get(slot, "") or "").strip() if isinstance(lmb_old, dict) else "", + "ability_images": slot_ai, + } + slot_chars[slot] = char_key + else: + slot_chars[slot] = "" + ww_teams[team_id] = { + "name": team_name, + "slot1": slot_chars.get("1", ""), + "slot2": slot_chars.get("2", ""), + "slot3": slot_chars.get("3", ""), + } + engine.ww.ww_teams = ww_teams + + active_team = str(data.get("ww_active_team_id") or "").strip() + engine.ww.ww_active_team_id = active_team if active_team in engine.ww.ww_teams else None + + combo_team = data.get("combo_ww_team", {}) + combo_ww_team: dict[str, str] = {} + if isinstance(combo_team, dict): + for k, v in combo_team.items(): + cname = str(k).strip() + tid = str(v).strip() + if not cname or not tid: + continue + if tid in engine.ww.ww_teams: + combo_ww_team[cname] = tid + engine.ww.combo_ww_team = combo_ww_team + + +def _migrate_legacy_combo_ability_images(engine, data: dict[str, Any]) -> None: + """Migration: old per-combo WW ability images -> character library + teams.""" + legacy = data.get("combo_ww_ability_images", {}) + if isinstance(legacy, dict): + for combo_name, mapping in legacy.items(): + cname = str(combo_name).strip() + if not cname or not isinstance(mapping, dict): + continue + per_char: dict[str, dict[str, str]] = {} + for ck, cm in mapping.items(): + c = str(ck or "").strip() + if c not in ("1", "2", "3") or not isinstance(cm, dict): + continue + m: dict[str, str] = {} + for akey, av in cm.items(): + a = str(akey or "").strip().lower() + if a not in ("e", "q", "r"): + continue + url = str(av or "").strip() + if url: + m[a] = url + if m: + per_char[c] = m + if not per_char: + continue + + team_id = uuid4().hex[:10] + slot_chars_legacy: dict[str, str] = {} + for slot in ("1", "2", "3"): + slot_abil = per_char.get(slot, {}) + char_display = f"Imported: {cname} {slot}" + char_key = char_display.lower() + suffix = 0 + while char_key in engine.ww.ww_characters: + suffix += 1 + char_key = f"{char_display.lower()}_{suffix}" + engine.ww.ww_characters[char_key] = { + "name": char_display, + "swap_image": "", + "lmb_image": "", + "ability_images": slot_abil, + } + slot_chars_legacy[slot] = char_key + + engine.ww.ww_teams[team_id] = { + "name": f"Imported: {cname}", + "slot1": slot_chars_legacy.get("1", ""), + "slot2": slot_chars_legacy.get("2", ""), + "slot3": slot_chars_legacy.get("3", ""), + } + engine.ww.combo_ww_team[cname] = team_id + if engine.ww.ww_active_team_id is None: + engine.ww.ww_active_team_id = team_id + + +def _load_macro_settings(engine, data: dict[str, Any]) -> None: + """No-fail mode and macro replay settings.""" + no_fail = data.get("no_fail_mode") + engine.no_fail_mode = bool(no_fail) if no_fail is not None else getattr(engine, "no_fail_mode", False) + + macro_start = data.get("macro_start_key") + if isinstance(macro_start, str) and macro_start.strip(): + engine.macro_start_key = macro_start.strip().lower() + else: + engine.macro_start_key = getattr(engine, "macro_start_key", "f8") or "f8" + + macro_stop = data.get("macro_stop_key") + if isinstance(macro_stop, str) and macro_stop.strip(): + engine.macro_stop_key = macro_stop.strip().lower() + else: + engine.macro_stop_key = getattr(engine, "macro_stop_key", "f9") or "f9" + + macro_spam_interval = data.get("macro_spam_interval_ms") + if macro_spam_interval in (None, ""): + engine.macro_spam_interval_ms = None + else: + try: + iv = int(macro_spam_interval) + except Exception: + fallback = getattr(engine, "macro_spam_interval_ms", 100) + if fallback in (None, ""): + iv = 100 + else: + iv = int(fallback) + engine.macro_spam_interval_ms = iv if iv > 0 else None + + def load_engine_state(engine) -> None: """ Load persisted state from `engine.state_store` into the engine instance. @@ -54,402 +458,15 @@ def load_engine_state(engine) -> None: if data is None: return - # Combos - combos = data.get("combos", {}) - if isinstance(combos, dict): - sanitized: dict[str, list[str]] = {} - for name, seq in combos.items(): - if not isinstance(name, str) or not isinstance(seq, list): - continue - sanitized[name] = [str(x).strip().lower() for x in seq if str(x).strip()] - engine.combos = sanitized - - # Enders - enders = data.get("combo_enders", {}) - parsed: dict[str, int] = {} - if isinstance(enders, dict): - for k, v in enders.items(): - key = str(k).strip().lower() - if not key: - continue - ms = _as_int(v, 0) - parsed[key] = max(0, ms) - elif isinstance(enders, list): - for x in enders: - key = str(x).strip().lower() - if key: - parsed[key] = 0 - engine.combo_enders = parsed - - # Soft enders (~key:2s): do not drop combo when pressed during hold - soft = data.get("combo_enders_soft", []) - if isinstance(soft, list): - engine.combo_enders_soft = {str(x).strip().lower() for x in soft if str(x).strip()} - else: - engine.combo_enders_soft = set() - - # Transcribe valid keys (comma-separated string) - tvk = data.get("transcribe_valid_keys") - if isinstance(tvk, str): - engine.transcribe_valid_keys = tvk.strip() - else: - engine.transcribe_valid_keys = getattr(engine, "transcribe_valid_keys", "") or "" - - tsk = data.get("transcribe_start_key") - if isinstance(tsk, str) and tsk.strip(): - engine.transcribe_start_key = tsk.strip().lower() - else: - engine.transcribe_start_key = getattr(engine, "transcribe_start_key", "f") or "f" - - tst = data.get("transcribe_strip_wait_under_enabled") - if isinstance(tst, bool): - engine.transcribe_strip_wait_under_enabled = tst - elif tst is None: - engine.transcribe_strip_wait_under_enabled = False - else: - engine.transcribe_strip_wait_under_enabled = bool(tst) - - tsms = data.get("transcribe_strip_wait_under_ms") - if isinstance(tsms, str): - engine.transcribe_strip_wait_under_ms = tsms.strip() - elif isinstance(tsms, (int, float)): - engine.transcribe_strip_wait_under_ms = str(int(tsms)) - elif tsms is None: - engine.transcribe_strip_wait_under_ms = "0" - else: - engine.transcribe_strip_wait_under_ms = str(tsms).strip() - - # Stats - stats = data.get("combo_stats", {}) - if isinstance(stats, dict): - cleaned: dict[str, dict[str, Any]] = {} - for k, v in stats.items(): - name = str(k).strip() - if not name or not isinstance(v, dict): - continue - s = max(0, _as_int(v.get("success", 0), 0)) - f = max(0, _as_int(v.get("fail", 0), 0)) - best_raw = v.get("best_ms") - best_ms = _as_int(best_raw, 0) if best_raw is not None else None - total_success_ms = max(0, _as_int(v.get("total_success_ms", 0), 0)) - - fail_by_step = _clean_counter_dict(v.get("fail_by_step", {}), key_norm=lambda kk: str(kk).strip()) - fail_by_expected = _clean_counter_dict( - v.get("fail_by_expected", {}), - key_norm=lambda kk: str(kk).strip().lower(), - ) - fail_by_reason = _clean_counter_dict( - v.get("fail_by_reason", {}), - key_norm=lambda kk: str(kk).strip().lower(), - ) - - # Keep only recent fail events to cap file growth. - fail_events = v.get("fail_events", []) - fes: list[dict[str, Any]] = [] - if isinstance(fail_events, list): - for ev in fail_events[-100:]: - if not isinstance(ev, dict): - continue - fes.append( - { - "ts": int(ev.get("ts", 0) or 0), - "attempt": max(0, _as_int(ev.get("attempt", 0), 0)), - "step_index": max(0, _as_int(ev.get("step_index", 0), 0)), - "expected": str(ev.get("expected", "") or ""), - "actual": str(ev.get("actual", "") or ""), - "reason": str(ev.get("reason", "") or ""), - "elapsed_ms": (_as_int(ev.get("elapsed_ms"), 0) if ev.get("elapsed_ms") is not None else None), - } - ) - - cleaned[name] = { - "success": s, - "fail": f, - "best_ms": best_ms, - "total_success_ms": total_success_ms, - "fail_by_step": fail_by_step, - "fail_by_expected": fail_by_expected, - "fail_by_reason": fail_by_reason, - "fail_events": fes, - } - engine.combo_stats = cleaned - - # Optional: per-combo expected execution time (ms) - exp = data.get("combo_expected_ms", {}) - expected_ms: dict[str, int] = {} - if isinstance(exp, dict): - for k, v in exp.items(): - name = str(k).strip() - if not name: - continue - ms = _as_int(v, -1) - if ms > 0: - expected_ms[name] = ms - engine.combo_expected_ms = expected_ms - - # Optional: per-combo user difficulty (0..10) - ud = data.get("combo_user_difficulty", {}) - user_diff: dict[str, float] = {} - if isinstance(ud, dict): - for k, v in ud.items(): - name = str(k).strip() - if not name: - continue - try: - d = float(v) - except Exception: - continue - if 0.0 <= d <= 10.0: - user_diff[name] = d - engine.combo_user_difficulty = user_diff - - # Optional: per-combo step display mode ("icons" | "images") - dm = data.get("combo_step_display_mode", {}) - display_mode: dict[str, str] = {} - if isinstance(dm, dict): - for k, v in dm.items(): - name = str(k).strip() - if not name: - continue - mode = str(v or "").strip().lower() - if mode in ("icons", "images"): - display_mode[name] = mode - engine.combo_step_display_mode = display_mode - - # Optional: per-combo key images mapping - ki = data.get("combo_key_images", {}) - key_images: dict[str, dict[str, str]] = {} - if isinstance(ki, dict): - for k, v in ki.items(): - name = str(k).strip() - if not name or not isinstance(v, dict): - continue - m: dict[str, str] = {} - for kk, vv in v.items(): - key = str(kk).strip().lower() - url = str(vv).strip() - if not key or not url: - continue - m[key] = url - if m: - key_images[name] = m - engine.combo_key_images = key_images - - # Per-combo demo video URL (e.g. YouTube) - dv = data.get("combo_demo_video", {}) - demo_video: dict[str, str] = {} - if isinstance(dv, dict): - for k, v in dv.items(): - name = str(k).strip() - url = str(v or "").strip() - if name and url: - demo_video[name] = url - engine.combo_demo_video = demo_video - - # ---- Wuthering Waves / target game ---- - # target game per combo - tg = data.get("combo_target_game", {}) - if isinstance(tg, dict): - for k, v in tg.items(): - name = str(k).strip() - if not name: - continue - g = str(v or "").strip().lower() - if g in ("generic", "wuthering_waves"): - engine.ww.combo_target_game[name] = g - - # WW character library - chars_raw = data.get("ww_characters", {}) - ww_characters: dict[str, Any] = {} - if isinstance(chars_raw, dict): - for ckey, cv in chars_raw.items(): - key = str(ckey or "").strip().lower() - if not key or not isinstance(cv, dict): - continue - ai: dict[str, str] = {} - ai_raw = cv.get("ability_images", {}) - if isinstance(ai_raw, dict): - for a, v in ai_raw.items(): - akey = str(a or "").strip().lower() - if akey in ("e", "q", "r"): - url = str(v or "").strip() - if url: - ai[akey] = url - ww_characters[key] = { - "name": str(cv.get("name", "") or key), - "swap_image": str(cv.get("swap_image", "") or "").strip(), - "lmb_image": str(cv.get("lmb_image", "") or "").strip(), - "ability_images": ai, - } - engine.ww.ww_characters = ww_characters - - # Global dash image - engine.ww.ww_dash_image = str(data.get("ww_dash_image", "") or "").strip() - - # WW teams (new slot-based format; migrates from old embedded-image format) - teams = data.get("ww_teams", {}) - ww_teams: dict[str, dict[str, Any]] = {} - if isinstance(teams, dict): - for tid, tv in teams.items(): - team_id = str(tid).strip() - if not team_id or not isinstance(tv, dict): - continue - team_name = str(tv.get("name", "") or "").strip() or "Team" - - if "slot1" in tv or "slot2" in tv or "slot3" in tv: - # New format - ww_teams[team_id] = { - "name": team_name, - "slot1": str(tv.get("slot1", "") or "").strip().lower(), - "slot2": str(tv.get("slot2", "") or "").strip().lower(), - "slot3": str(tv.get("slot3", "") or "").strip().lower(), - } - else: - # Old format: migrate embedded swap/lmb/ability per slot into characters - dash_old = str(tv.get("dash_image", "") or "").strip() - if dash_old and not engine.ww.ww_dash_image: - engine.ww.ww_dash_image = dash_old - swap_old = tv.get("swap_images") or {} - lmb_old = tv.get("lmb_images") or {} - abil_old = tv.get("ability_images") or {} - slot_chars: dict[str, str] = {} - for slot in ("1", "2", "3"): - has_swap = isinstance(swap_old, dict) and str(swap_old.get(slot, "") or "").strip() - has_lmb = isinstance(lmb_old, dict) and str(lmb_old.get(slot, "") or "").strip() - has_abil = ( - isinstance(abil_old, dict) - and isinstance(abil_old.get(slot), dict) - and any(str(v or "").strip() for v in abil_old[slot].values()) - ) - if has_swap or has_lmb or has_abil: - char_display = f"{team_name} {slot}" - char_key = char_display.lower() - suffix = 0 - while char_key in engine.ww.ww_characters: - suffix += 1 - char_key = f"{char_display.lower()}_{suffix}" - char_display = f"{team_name} {slot}_{suffix}" - slot_ai: dict[str, str] = {} - if isinstance(abil_old, dict) and isinstance(abil_old.get(slot), dict): - for a, v in abil_old[slot].items(): - akey = str(a or "").strip().lower() - if akey in ("e", "q", "r"): - url = str(v or "").strip() - if url: - slot_ai[akey] = url - engine.ww.ww_characters[char_key] = { - "name": char_display, - "swap_image": str(swap_old.get(slot, "") or "").strip() if isinstance(swap_old, dict) else "", - "lmb_image": str(lmb_old.get(slot, "") or "").strip() if isinstance(lmb_old, dict) else "", - "ability_images": slot_ai, - } - slot_chars[slot] = char_key - else: - slot_chars[slot] = "" - ww_teams[team_id] = { - "name": team_name, - "slot1": slot_chars.get("1", ""), - "slot2": slot_chars.get("2", ""), - "slot3": slot_chars.get("3", ""), - } - engine.ww.ww_teams = ww_teams - - active_team = str(data.get("ww_active_team_id") or "").strip() - engine.ww.ww_active_team_id = active_team if active_team in engine.ww.ww_teams else None - - combo_team = data.get("combo_ww_team", {}) - combo_ww_team: dict[str, str] = {} - if isinstance(combo_team, dict): - for k, v in combo_team.items(): - cname = str(k).strip() - tid = str(v).strip() - if not cname or not tid: - continue - if tid in engine.ww.ww_teams: - combo_ww_team[cname] = tid - engine.ww.combo_ww_team = combo_ww_team - - # Migration: old per-combo ww ability images -> character library + teams - legacy = data.get("combo_ww_ability_images", {}) - if isinstance(legacy, dict): - for combo_name, mapping in legacy.items(): - cname = str(combo_name).strip() - if not cname or not isinstance(mapping, dict): - continue - per_char: dict[str, dict[str, str]] = {} - for ck, cm in mapping.items(): - c = str(ck or "").strip() - if c not in ("1", "2", "3") or not isinstance(cm, dict): - continue - m: dict[str, str] = {} - for akey, av in cm.items(): - a = str(akey or "").strip().lower() - if a not in ("e", "q", "r"): - continue - url = str(av or "").strip() - if url: - m[a] = url - if m: - per_char[c] = m - if not per_char: - continue - - team_id = uuid4().hex[:10] - slot_chars_legacy: dict[str, str] = {} - for slot in ("1", "2", "3"): - slot_abil = per_char.get(slot, {}) - char_display = f"Imported: {cname} {slot}" - char_key = char_display.lower() - suffix = 0 - while char_key in engine.ww.ww_characters: - suffix += 1 - char_key = f"{char_display.lower()}_{suffix}" - engine.ww.ww_characters[char_key] = { - "name": char_display, - "swap_image": "", - "lmb_image": "", - "ability_images": slot_abil, - } - slot_chars_legacy[slot] = char_key - - engine.ww.ww_teams[team_id] = { - "name": f"Imported: {cname}", - "slot1": slot_chars_legacy.get("1", ""), - "slot2": slot_chars_legacy.get("2", ""), - "slot3": slot_chars_legacy.get("3", ""), - } - engine.ww.combo_ww_team[cname] = team_id - if engine.ww.ww_active_team_id is None: - engine.ww.ww_active_team_id = team_id - - no_fail = data.get("no_fail_mode") - engine.no_fail_mode = bool(no_fail) if no_fail is not None else getattr(engine, "no_fail_mode", False) - - macro_start = data.get("macro_start_key") - if isinstance(macro_start, str) and macro_start.strip(): - engine.macro_start_key = macro_start.strip().lower() - else: - engine.macro_start_key = getattr(engine, "macro_start_key", "f8") or "f8" - - macro_stop = data.get("macro_stop_key") - if isinstance(macro_stop, str) and macro_stop.strip(): - engine.macro_stop_key = macro_stop.strip().lower() - else: - engine.macro_stop_key = getattr(engine, "macro_stop_key", "f9") or "f9" - - macro_spam_interval = data.get("macro_spam_interval_ms") - if macro_spam_interval in (None, ""): - engine.macro_spam_interval_ms = None - else: - try: - iv = int(macro_spam_interval) - except Exception: - fallback = getattr(engine, "macro_spam_interval_ms", 100) - if fallback in (None, ""): - iv = 100 - else: - iv = int(fallback) - engine.macro_spam_interval_ms = iv if iv > 0 else None + _load_combos(engine, data) + _load_enders(engine, data) + _load_transcribe_settings(engine, data) + _load_stats(engine, data) + _load_combo_options(engine, data) + _load_ww_characters(engine, data) + _load_ww_teams(engine, data) + _migrate_legacy_combo_ability_images(engine, data) + _load_macro_settings(engine, data) last_active = data.get("last_active_combo") if last_active in engine.combos: @@ -466,7 +483,6 @@ def load_engine_state(engine) -> None: engine.combo_step_display_mode = {} engine.combo_key_images = {} engine.combo_demo_video = {} - engine.ww.combo_target_game = {} engine.ww.ww_characters = {} engine.ww.ww_dash_image = "" engine.ww.ww_teams = {} @@ -507,7 +523,6 @@ def save_engine_state(engine) -> bool: "combo_step_display_mode": dict(engine.combo_step_display_mode), "combo_key_images": dict(engine.combo_key_images), "combo_demo_video": dict(getattr(engine, "combo_demo_video", {})), - "combo_target_game": dict(engine.ww.combo_target_game), "ww_characters": dict(engine.ww.ww_characters), "ww_dash_image": engine.ww.ww_dash_image, "ww_teams": { diff --git a/requirements.txt b/requirements.txt index 28dc276..ff86f89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ pynput==1.8.1 -websockets==12.0 +websockets==15.0.1 diff --git a/state_store.py b/state_store.py index 33d27da..7e734c6 100644 --- a/state_store.py +++ b/state_store.py @@ -43,8 +43,10 @@ def save(self, payload: dict[str, Any]) -> None: class JsonStateStore: """Atomic JSON adapter with one validated, known-good backup.""" - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, seed_path: Path | None = None) -> None: self.path = Path(path) + # Starter data used only on first run, when no save file or backup exists yet. + self.seed_path = Path(seed_path) if seed_path else None self.backup_path = self.path.with_suffix(self.path.suffix + ".bak") self._lock = threading.Lock() @@ -60,6 +62,11 @@ def load(self) -> dict[str, Any] | None: if not self.path.exists(): if self.backup_path.exists(): return self._read_json(self.backup_path) + if self.seed_path and self.seed_path.exists(): + try: + return self._read_json(self.seed_path) + except Exception: + logger.warning("Ignoring unreadable starter file %s", self.seed_path, exc_info=True) return None try: return self._read_json(self.path) diff --git a/states.py b/states.py index bc4a550..de59a9c 100644 --- a/states.py +++ b/states.py @@ -888,6 +888,3 @@ def build_runtime_state(node: StepNode) -> StepState: # Test helpers # --------------------------------------------------------------------------- -def steps_from_ast(ast_steps: list[StepNode]) -> list[StepState]: - """Build runtime states from AST nodes.""" - return [build_runtime_state(node) for node in ast_steps] diff --git a/static/app.js b/static/app.js index d681145..b272c67 100644 --- a/static/app.js +++ b/static/app.js @@ -1,5 +1,8 @@ -// WebSocket connection -const ws = new WebSocket('ws://localhost:8765'); +// WebSocket connection (opened by connectWebSocket() at the end of this file) +const WS_URL = 'ws://localhost:8765'; +const WS_RECONNECT_MAX_MS = 5000; +let ws = null; +let wsReconnectDelayMs = 500; const getEl = (id) => document.getElementById(id); @@ -8,7 +11,7 @@ const SHORT_WAIT_MS = 150; // Timeline-only view for OBS (Browser Source or Window Capture) if (new URLSearchParams(window.location.search).get('view') === 'timeline') { - document.title = 'ComboTracker – Timeline'; + document.title = 'WuWa Combo Tracker – Timeline'; document.body.classList.add('timeline-window-view'); } @@ -17,20 +20,59 @@ function getTimelineUrl() { return window.location.origin + path + (path.includes('?') ? '&' : '?') + 'view=timeline'; } -ws.onopen = () => { - console.log('Connected to Combo Trainer backend'); -}; - -ws.onclose = () => { - console.error('Connection lost. Please restart the application.'); - updateStatus('ERROR: Backend disconnected', 'fail'); -}; +/** Connect to the backend, retrying with backoff so the UI (and OBS overlays) recover on their own after a restart. */ +function connectWebSocket() { + const socket = new WebSocket(WS_URL); + ws = socket; + socket.onopen = () => { + console.log('Connected to WuWa Combo Tracker backend'); + wsReconnectDelayMs = 500; + updateStatus('Status: Ready', 'neutral'); + }; + socket.onclose = () => { + if (ws !== socket) return; + console.warn(`Backend connection lost; retrying in ${wsReconnectDelayMs}ms`); + updateStatus('Backend disconnected. Reconnecting…', 'fail'); + setTimeout(connectWebSocket, wsReconnectDelayMs); + wsReconnectDelayMs = Math.min(wsReconnectDelayMs * 2, WS_RECONNECT_MAX_MS); + }; + socket.onmessage = (event) => { + const msg = JSON.parse(event.data); + appState.batchQueue.push(msg); + if (!appState.isProcessingBatch) processBatch(); + }; +} function sendMessage(type, payload = {}) { - if (ws.readyState !== WebSocket.OPEN) return; + if (!ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type, ...payload })); } +/** Per-browser display preference; storage can be unavailable (private windows, OBS), so fall back quietly. */ +function readStoredFlag(key, fallback) { + try { + const v = localStorage.getItem(key); + return v === null ? fallback : v === '1'; + } catch (_) { + return fallback; + } +} + +function writeStoredFlag(key, value) { + try { localStorage.setItem(key, value ? '1' : '0'); } catch (_) { /* ignore */ } +} + +/** Character names for team slots '1'/'2'/'3' of the selected team. */ +function wwSlotNames() { + const out = {}; + WW_SLOTS.forEach((slot, i) => { + const key = (appState.wwTeamSlots[i] || '').toString(); + const ch = key ? appState.wwCharacters[key] : null; + if (ch && ch.name) out[slot] = ch.name; + }); + return out; +} + // Single app state (replaces scattered globals) const appState = { stepDisplayMode: 'images', @@ -39,10 +81,11 @@ const appState = { lastFailByStep: {}, showFailCount: false, collapseChainedPresses: true, + showMoveNames: readStoredFlag('showMoveNames', true), autoScrollEnabled: false, stepEditMode: true, editStepsUndoStack: [], - targetGame: 'generic', + targetGame: 'wuthering_waves', wwAbilityImages: { "1": {}, "2": {}, "3": {} }, wwSwapImages: { "1": "", "2": "", "3": "" }, wwLmbImages: { "1": "", "2": "", "3": "" }, @@ -148,9 +191,9 @@ function initializeUI(data) { } } -function normalizeTargetGame(v) { - const g = (v || '').toString().trim().toLowerCase(); - return (g === 'wuthering_waves') ? 'wuthering_waves' : 'generic'; +// ComboTracker is Wuthering Waves only; every combo uses the WW team/character features. +function normalizeTargetGame(_v) { + return 'wuthering_waves'; } function ensureWwAbilityShape(obj) { @@ -842,40 +885,51 @@ function updateStatus(text, color) { el.className = 'status-' + (color || 'neutral'); } -// Stats +// Stats: the backend sends "Label: value" texts; cards show just the value. +function setStatValue(id, text) { + const el = getEl(id)?.querySelector('.stat-value'); + if (!el) return; + const raw = (text || '').toString(); + const i = raw.indexOf(':'); + const value = (i >= 0 ? raw.slice(i + 1) : raw).trim(); + el.textContent = value || '—'; + el.title = raw; +} + +// e.g. "Stats: 3 success / 1 fail (75%) | Best: 12.3s | Avg: 13.0s" function updateStats(text) { - const el = getEl('statsDisplay'); - if (el) el.textContent = text || 'Stats: —'; + const parts = (text || '').toString().replace(/^Stats:\s*/, '').split('|').map(s => s.trim()); + const result = (parts[0] || '').replace(/ success \/ /, ' / ').replace(/ fail/, ''); + setStatValue('statsResult', /^0 \/ 0\b/.test(result) ? '' : result); // no attempts yet + setStatValue('statsBest', parts[1] || ''); + setStatValue('statsAvg', parts[2] || ''); } function updateMinTime(text) { - const el = getEl('minTimeDisplay'); - if (el) el.textContent = text || 'Fastest possible: —'; + // Drop the "(53700ms)" repeat of the value to keep the card short. + setStatValue('minTimeDisplay', (text || '').toString().replace(/\s*\(\d+ms\)\s*$/, '')); } function updateDifficulty(text) { - const el = getEl('difficultyDisplay'); - if (el) el.textContent = text || 'Difficulty: —'; + setStatValue('difficultyDisplay', text); } function updateUserDifficulty(text) { - const el = getEl('userDifficultyDisplay'); - if (el) el.textContent = text || 'Your difficulty: —'; + setStatValue('userDifficultyDisplay', text); } function updateAPM(text) { - const el = getEl('apmDisplay'); - if (el) el.textContent = text || 'Practical APM: —'; + setStatValue('apmDisplay', text); } function updateAPMMax(text) { - const el = getEl('apmMaxDisplay'); - if (el) el.textContent = text || 'Theoretical max APM: —'; + setStatValue('apmMaxDisplay', text); } function setDifficultyColor(el, value) { if (!el) return; el.classList.remove('diff-easy', 'diff-med', 'diff-hard', 'diff-insane'); + if (value === null || value === undefined || value === '') return; // unrated, not "easy" const v = Number(value); if (!Number.isFinite(v)) return; if (v < 3) el.classList.add('diff-easy'); @@ -1673,6 +1727,19 @@ function updateTimeline(steps, opts) { showFailCount: appState.showFailCount, }; + // Names each step's move ("Zani Basic 2"); steps must be labeled in timeline order. + const labelMove = appState.showMoveNames ? createWwMoveLabeler(wwSlotNames()) : null; + function appendMoveLabel(tile, step, slot) { + if (!labelMove) return; + const text = labelMove(step, slot); + if (!text) return; + const el = document.createElement('span'); + el.className = 'step-move'; + el.textContent = text; + el.title = text; + tile.appendChild(el); + } + const viewport = getEl('comboTimelineViewport'); const isAutoScroll = viewport?.classList.contains('auto-scroll-on'); let baseStepWidthPx = 90; @@ -1841,6 +1908,7 @@ function updateTimeline(steps, opts) { if (it.completed) el.classList.add('completed'); appendStepContent(el, it, characterId, ctx); + appendMoveLabel(el, it, characterId); let keyForCorner = ''; if (it.type === 'wait' && it.wait_for) keyForCorner = it.wait_for; @@ -1945,6 +2013,7 @@ function updateTimeline(steps, opts) { if (it.active) itEl.classList.add('active'); if (it.completed) itEl.classList.add('completed'); appendStepContent(itEl, it, nextChar, ctx); + appendMoveLabel(itEl, it, nextChar); items.appendChild(itEl); }); tile.appendChild(items); @@ -2001,6 +2070,7 @@ function updateTimeline(steps, opts) { if (keyForCorner && s.type !== 'hold_with_body') addCornerKey(tile, keyForCorner, s, runtimeIdxNormal); appendStepContent(tile, s, nextChar, ctx, runtimeIdxNormal); + appendMoveLabel(tile, s, nextChar); attachStepDeleteControl(tile, s.step_indices); attachStepDragControl(tile, s.step_indices); return { tile, nextActiveChar: nextChar }; @@ -2633,6 +2703,16 @@ if (showFailCountEl) { }); } +const moveNamesToggleEl = getEl('moveNamesToggle'); +if (moveNamesToggleEl) { + moveNamesToggleEl.checked = !!appState.showMoveNames; + moveNamesToggleEl.addEventListener('change', () => { + appState.showMoveNames = moveNamesToggleEl.checked; + writeStoredFlag('showMoveNames', appState.showMoveNames); + refreshTimelineIfLoaded(); + }); +} + const stepEditToggleEl = getEl('stepEditToggle'); if (stepEditToggleEl) { stepEditToggleEl.checked = !!appState.stepEditMode; @@ -2651,6 +2731,16 @@ if (collapseChainsToggleEl) { }); } +const legendBtn = getEl('legendBtn'); +if (legendBtn) { + legendBtn.addEventListener('click', () => { + const panel = getEl('legendPanel'); + if (!panel) return; + const open = panel.classList.toggle('hidden') === false; + legendBtn.setAttribute('aria-expanded', String(open)); + }); +} + const copyOverlayUrlBtn = getEl('copyOverlayUrlBtn'); if (copyOverlayUrlBtn) { copyOverlayUrlBtn.addEventListener('click', () => { @@ -2901,16 +2991,19 @@ if (keyImagesDetails) { }); } -const targetGameEl = getEl('targetGameSelect'); -if (targetGameEl) { - targetGameEl.addEventListener('change', () => { - appState.targetGame = normalizeTargetGame(targetGameEl.value); - // Send target game change to backend immediately for stateless operation - sendMessage('update_target_game', { target_game: appState.targetGame }); - syncGameUIVisibility(); - refreshTimelineIfLoaded(); - }); +// WW: one-click character sync from wuthering.gg +const wwSyncCharNameEl = getEl('wwSyncCharName'); +function syncCharacterFromWeb() { + const name = (wwSyncCharNameEl?.value || '').toString().trim(); + if (!name) { updateStatus('Type a character name first.', 'fail'); return; } + sendMessage('sync_character', { name }); + wwSyncCharNameEl.value = ''; } +getEl('wwSyncCharBtn')?.addEventListener('click', syncCharacterFromWeb); +wwSyncCharNameEl?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); syncCharacterFromWeb(); } +}); +getEl('wwSyncAllBtn')?.addEventListener('click', () => sendMessage('sync_all_characters')); // WW: team select dropdown document.addEventListener('change', e => { @@ -3055,8 +3148,4 @@ function processBatch() { }); } -ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - appState.batchQueue.push(msg); - if (!appState.isProcessingBatch) processBatch(); -}; +connectWebSocket(); diff --git a/static/characters.css b/static/characters.css new file mode 100644 index 0000000..6cec4bb --- /dev/null +++ b/static/characters.css @@ -0,0 +1,291 @@ +/* Characters page. Colors match style.css (the main tracker page). */ +:root { + --bg-primary: #1a1a1a; + --bg-secondary: #2a2a2a; + --bg-hover: #3a3a3a; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --accent-blue: #4a9eff; + --accent-green: #4aff88; + --accent-yellow: #ffdd4a; + --accent-red: #ff4a4a; + --radius: 8px; + + --el-aero: #55d6a8; + --el-electro: #b67cff; + --el-fusion: #ff7a4a; + --el-glacio: #5cc8ff; + --el-havoc: #d14a8b; + --el-spectro: #f0d36b; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: 'Segoe UI', 'Roboto', sans-serif; + font-size: 14px; + line-height: 1.5; +} + +a { color: var(--accent-blue); } +.hidden { display: none !important; } +.muted { color: var(--text-secondary); } +.small { font-size: 12px; } + +button, .btn { + background: var(--bg-hover); + color: var(--text-primary); + border: 1px solid #4a4a4a; + border-radius: 6px; + padding: 6px 12px; + font: inherit; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; +} +button:hover, .btn:hover { border-color: var(--accent-blue); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button.subtle, .btn.subtle { background: transparent; } + +input[type="search"], select, textarea { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid #4a4a4a; + border-radius: 6px; + padding: 6px 10px; + font: inherit; +} + +/* --- Top bar --- */ +.topbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + padding: 12px 20px; + background: var(--bg-secondary); + border-bottom: 1px solid #333; +} +.topbar h1 { font-size: 18px; } +.topbar .back { text-decoration: none; color: var(--text-secondary); } +.topbar .back:hover { color: var(--text-primary); } +.topbar input[type="search"] { width: 200px; } +.topbar #refreshBtn { margin-left: auto; } + +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { padding: 3px 10px; border-radius: 999px; font-size: 13px; } +.chip.on { background: var(--accent-blue); border-color: var(--accent-blue); color: #0b1a2c; } + +.dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + background: var(--text-secondary); + vertical-align: middle; +} +.el-aero { background: var(--el-aero); } +.el-electro { background: var(--el-electro); } +.el-fusion { background: var(--el-fusion); } +.el-glacio { background: var(--el-glacio); } +.el-havoc { background: var(--el-havoc); } +.el-spectro { background: var(--el-spectro); } + +.notice { + margin: 12px 20px 0; + padding: 8px 12px; + border-radius: 6px; + background: var(--bg-secondary); + border-left: 3px solid var(--accent-blue); +} +.notice.warn { border-left-color: var(--accent-yellow); } +.notice.ok { border-left-color: var(--accent-green); } + +/* --- Layout --- */ +.layout { + display: grid; + grid-template-columns: minmax(260px, 360px) 1fr; + gap: 20px; + padding: 20px; + max-width: 1400px; + margin: 0 auto; + align-items: start; +} + +.roster { + position: sticky; + top: 76px; + max-height: calc(100vh - 96px); + overflow-y: auto; + padding-right: 4px; +} +.roster h2 { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + margin: 4px 0 8px; +} +.roster .grid + h2 { margin-top: 16px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; } + +.char { + flex-direction: column; + padding: 8px 4px 6px; + background: var(--bg-secondary); + border-color: transparent; + min-width: 0; +} +.char img, .char .noimg { width: 56px; height: 56px; border-radius: 50%; background: var(--bg-hover); object-fit: cover; } +.char-name { + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.char-name .dot { width: 6px; height: 6px; margin-right: 4px; } +.char.selected { border-color: var(--accent-blue); background: #23303f; } + +.detail { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 20px; + min-height: 300px; +} +.empty, .loading { color: var(--text-secondary); padding: 40px 0; text-align: center; } +.error { color: var(--accent-red); padding: 20px 0; } + +.detail-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; } +.detail-head img { width: 72px; height: 72px; border-radius: 50%; background: var(--bg-hover); } +.detail-head h2 { font-size: 22px; } + +.tabs { display: flex; gap: 4px; border-bottom: 1px solid #3a3a3a; margin-bottom: 16px; } +.tabs button { + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + padding: 8px 14px; + color: var(--text-secondary); +} +.tabs button.on { color: var(--text-primary); border-bottom-color: var(--accent-blue); } +.count { background: var(--bg-hover); border-radius: 999px; padding: 0 7px; font-size: 12px; } + +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 12px; } +.toolbar .src { margin-left: auto; font-size: 12px; } + +/* --- Moves --- */ +.skill { background: var(--bg-primary); border-radius: var(--radius); margin-bottom: 10px; } +.skill summary { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + cursor: pointer; + list-style: none; +} +.skill summary::-webkit-details-marker { display: none; } +.skill summary img, .skill summary .noimg { width: 40px; height: 40px; border-radius: 8px; background: var(--bg-hover); } +.skill-title { display: flex; flex-direction: column; line-height: 1.3; flex: 1; min-width: 0; } +.skill-type { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); } +.skill-name { font-weight: 600; } +.keycap { + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + border: 1px solid #555; + border-bottom-width: 3px; + border-radius: 5px; + padding: 1px 7px; + white-space: nowrap; +} +.skill-body { + display: grid; + grid-template-columns: 1fr minmax(200px, 280px); + gap: 16px; + padding: 0 14px 14px; +} +.desc h5 { font-size: 13px; color: var(--accent-yellow); margin: 10px 0 2px; } +.desc h5:first-child { margin-top: 0; } +.desc p { margin: 0 0 4px; color: #cfcfcf; } +.mult { border-collapse: collapse; width: 100%; font-size: 13px; align-self: start; } +.mult th, .mult td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; } +.mult th { color: var(--text-secondary); font-weight: 500; } +.mult td:last-child { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* --- Rotations --- */ +.detail h3 { font-size: 15px; margin: 18px 0 10px; } +.detail h3:first-child { margin-top: 0; } +.thoughts { + border-left: 3px solid var(--accent-blue); + padding: 6px 12px; + margin-bottom: 12px; + color: #cfcfcf; + white-space: pre-line; + background: var(--bg-primary); + border-radius: 0 6px 6px 0; +} +.team { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 6px 16px; + background: var(--bg-primary); + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 8px; +} +.team-name { font-weight: 600; } +.team-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 12px; margin: 2px 0 6px; } +.tag { background: #23303f; color: #a9cfff; border-radius: 4px; padding: 0 6px; } +.tag.subtle { background: var(--bg-hover); color: var(--text-primary); } +.dps-bar { height: 4px; background: #333; border-radius: 2px; overflow: hidden; } +.dps-bar span { display: block; height: 100%; background: var(--accent-blue); } +.team-num { text-align: right; display: flex; flex-direction: column; line-height: 1.2; font-variant-numeric: tabular-nums; } +.team-num .muted { font-size: 11px; } +.team-actions { display: flex; gap: 6px; } +.team-actions button, .team-actions .btn { padding: 4px 10px; font-size: 13px; } +.transcript { grid-column: 1 / -1; border-top: 1px solid #333; padding-top: 10px; margin-top: 4px; } +.transcript h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); margin: 10px 0 6px; } +.transcript h4:first-child { margin-top: 0; } +.step { display: flex; gap: 10px; align-items: baseline; padding: 3px 0; } +.who { width: 92px; flex: none; text-transform: capitalize; font-weight: 600; color: var(--accent-yellow); } +.moves { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } +.move { + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + background: var(--bg-hover); + border-radius: 4px; + padding: 1px 6px; +} +.move[title] { cursor: help; text-decoration: underline dotted #777; } +.move.swap { background: #23303f; color: #a9cfff; } +.arrow { color: #666; } +.glossary { margin-top: 10px; font-size: 12px; } +.glossary summary { cursor: pointer; color: var(--text-secondary); } +.glossary div { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 6px; } +.as-inputs { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; } +.as-inputs label { font-size: 12px; color: var(--text-secondary); } +.as-inputs textarea { font-family: ui-monospace, Consolas, monospace; font-size: 12px; resize: vertical; } +.as-inputs .row { display: flex; flex-wrap: wrap; gap: 6px; } +.src-line { margin-top: 14px; font-size: 12px; color: var(--text-secondary); } + +/* --- Phones / narrow windows --- */ +@media (max-width: 900px) { + .layout { grid-template-columns: 1fr; padding: 12px; } + .roster { position: static; max-height: 260px; } + .skill-body { grid-template-columns: 1fr; } + .team { grid-template-columns: 1fr auto; } + .team-actions { grid-column: 1 / -1; } + .topbar { padding: 10px 12px; position: static; } + .topbar input[type="search"] { flex: 1; width: auto; } + .who { width: 70px; } +} diff --git a/static/characters.html b/static/characters.html new file mode 100644 index 0000000..9929a11 --- /dev/null +++ b/static/characters.html @@ -0,0 +1,35 @@ + + + + + + + WuWa Characters + + + + + +
+ ← Combo Tracker +

Characters

+ +
+ +
+ + + +
+ + +
+
Pick a character to see their moves and team rotations.
+
+
+ + + + + + diff --git a/static/characters.js b/static/characters.js new file mode 100644 index 0000000..e36a7d6 --- /dev/null +++ b/static/characters.js @@ -0,0 +1,480 @@ +// Characters page: every character's moves (encore.moe) and community team rotations +// (AntoCrasher's calc compilation + rotation transcripts), served by ui_server.py under /api/ww/. + +const WS_URL = 'ws://localhost:8765'; +const ELEMENTS = ['Aero', 'Electro', 'Fusion', 'Glacio', 'Havoc', 'Spectro']; +const MAX_SKILL_LEVEL = 10; + +// Abbreviations from AntoCrasher's rotation hub; each rotation's own list (if any) wins. +const DEFAULT_GLOSSARY = { + ba: 'basic attack', fba: 'forte basic attack', uba: 'ultimate basic attack', ha: 'heavy attack', + fha: 'forte heavy attack', skill: 'resonance skill', eskill: 'enhanced skill', fskill: 'forte skill', + lib: 'liberation', echo: 'echo skill', tbs: 'tune break skill', nf: 'nightfall (Zani)', + dash: 'dodge', swap: 'swap to the next character', outro: 'outro skill (swap out)', +}; + +const state = { + roster: [], + rotations: [], // [{character, thoughts, teams: [...]}] + rotationsSource: '', + mine: [], // nameTokens() of characters saved in the tracker + element: '', + query: '', + selectedId: null, + tab: 'moves', + level: MAX_SKILL_LEVEL, + kits: new Map(), + transcripts: new Map(), + socket: null, +}; + +const $ = (id) => document.getElementById(id); + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +// "Rover: Spectro" / "Rover" / "rover" all compare as "rover". +function nameKey(name) { + return String(name || '').toLowerCase().split(/[:(]/)[0].trim(); +} + +// "Spectro_Rover" -> "rover spectro", "Yangyang: Xuanling" -> "xuanling yangyang" (word order ignored). +function nameTokens(name) { + return String(name || '').toLowerCase().split(/[^a-z]+/).filter(Boolean).sort().join(' '); +} + +// Saved tracker names are typed by hand ("Pheobe", "Agusta"), so allow one typo or swapped pair. +function closeEnough(a, b) { + if (a === b) return true; + if (Math.min(a.length, b.length) < 5 || Math.abs(a.length - b.length) > 1) return false; + const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); + for (let j = 1; j <= b.length; j++) d[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1); + } + } + return d[a.length][b.length] <= 1; +} + +function isMine(c) { + const t = nameTokens(c.name); + return state.mine.some((m) => closeEnough(m, t)); +} + +function safeUrl(u) { + return /^https:\/\//i.test(String(u || '')) ? String(u) : ''; +} + +function fmtNum(n) { + return n == null ? '—' : Math.round(n).toLocaleString(); +} + +function showNotice(text, kind = 'info') { + const el = $('notice'); + if (!text) { + el.classList.add('hidden'); + return; + } + el.textContent = text; + el.className = `notice ${kind}`; +} + +async function api(path, refresh = false) { + const url = `/api/ww/${path}${refresh ? (path.includes('?') ? '&' : '?') + 'refresh=1' : ''}`; + const res = await fetch(url); + const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + if (data.stale) showNotice(`Showing saved data; couldn't refresh (${data.error}).`, 'warn'); + return data; +} + +// --------------------------------------------------------------------------- +// Tracker connection (optional): marks your characters and saves rotations as combos +// --------------------------------------------------------------------------- + +function connectTracker() { + let socket; + try { + socket = new WebSocket(WS_URL); + } catch { + return; + } + socket.onmessage = (ev) => { + let msg; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + // The character list rides along in the editor payload (init and later editor updates). + const chars = (msg.editor && msg.editor.ww_characters) || msg.ww_characters; + if (Array.isArray(chars)) { + state.mine = chars.map((c) => nameTokens(c.name || c.name_key)); + renderRoster(); + } + if (msg.type === 'status' && msg.text && msg.color === 'fail') { + showNotice(msg.text, 'warn'); + } + }; + socket.onopen = () => (state.socket = socket); + socket.onclose = () => { + state.socket = null; + setTimeout(connectTracker, 3000); + }; +} + +// --------------------------------------------------------------------------- +// Roster +// --------------------------------------------------------------------------- + +function renderFilters() { + const chips = ['', ...ELEMENTS].map((el) => { + const label = el || 'All'; + const on = state.element === el ? ' on' : ''; + const dot = el ? `` : ''; + return ``; + }); + $('elementFilter').innerHTML = chips.join(''); +} + +function rosterCard(c) { + const sel = c.id === state.selectedId ? ' selected' : ''; + const icon = safeUrl(c.icon); + return ``; +} + +function renderRoster() { + const q = state.query.toLowerCase(); + const list = state.roster.filter( + (c) => (!state.element || c.element === state.element) && (!q || c.name.toLowerCase().includes(q)) + ); + const mine = list.filter(isMine); + const rest = list.filter((c) => !isMine(c)); + let html = ''; + if (mine.length) html += `

Your characters

${mine.map(rosterCard).join('')}
`; + if (rest.length) html += `${mine.length ? '

Everyone else

' : ''}
${rest.map(rosterCard).join('')}
`; + $('roster').innerHTML = html || '

No characters match.

'; +} + +// --------------------------------------------------------------------------- +// Detail +// --------------------------------------------------------------------------- + +function rotationsFor(name) { + const key = nameKey(name); + const main = state.rotations.find((g) => nameKey(g.character) === key); + const featured = []; + for (const g of state.rotations) { + if (g === main) continue; + for (const t of g.teams) { + if (t.members.slice(1).some((m) => nameKey(m) === key)) featured.push({ ...t, main: g.character }); + } + } + featured.sort((a, b) => (b.dps || 0) - (a.dps || 0)); + return { main, featured }; +} + +async function selectCharacter(id, { pushHash = true } = {}) { + state.selectedId = id; + if (pushHash) history.replaceState(null, '', `#${id}`); + renderRoster(); + const c = state.roster.find((x) => x.id === id); + if (!c) return; + const detail = $('detail'); + detail.innerHTML = `${detailHeader(c)}
Loading ${esc(c.name)}'s kit…
`; + if (window.innerWidth < 900) detail.scrollIntoView({ block: 'start', behavior: 'smooth' }); + try { + if (!state.kits.has(id)) state.kits.set(id, await api(`kit/${id}`)); + } catch (e) { + if (state.selectedId === id) detail.innerHTML = `${detailHeader(c)}
Couldn't load the kit: ${esc(e.message)}
`; + return; + } + if (state.selectedId === id) renderDetail(); +} + +function detailHeader(c) { + const icon = safeUrl(c.icon); + const { main, featured } = rotationsFor(c.name); + const count = (main ? main.teams.length : 0) + featured.length; + return `
+ ${icon ? `` : ''} +
+

${esc(c.name)}

+
${esc(c.element)} · ${esc(c.weapon)} · ${'★'.repeat(c.rarity || 0)}
+
+
+
+ + +
`; +} + +function renderDetail() { + const c = state.roster.find((x) => x.id === state.selectedId); + if (!c) return; + const body = state.tab === 'moves' ? renderMoves(state.kits.get(c.id)) : renderRotations(c); + $('detail').innerHTML = detailHeader(c) + body; +} + +// --- Moves ------------------------------------------------------------------ + +function formatDescription(text) { + return String(text || '') + .split(/\n+/) + .map((line) => { + const heading = line.match(/^\*\*(.+)\*\*$/); + if (heading) return `
${esc(heading[1])}
`; + return `

${esc(line).replace(/\*\*(.+?)\*\*/g, '$1')}

`; + }) + .join(''); +} + +function renderMoves(kit) { + if (!kit) return ''; + const levels = Array.from({ length: MAX_SKILL_LEVEL }, (_, i) => i + 1) + .map((l) => ``) + .join(''); + const cards = kit.skills.map((s) => { + const icon = safeUrl(s.icon); + const rows = s.multipliers + .map((m) => { + const v = m.values[Math.min(state.level, m.values.length) - 1] ?? m.values[0] ?? ''; + return `${esc(m.name)}${esc(v)}`; + }) + .join(''); + const open = s.type === 'Normal Attack' || s.type === 'Resonance Skill' || s.type === 'Resonance Liberation'; + return `
+ + ${icon ? `` : ''} + ${esc(s.type)}${esc(s.name)} + ${s.key ? `${esc(s.key)}` : ''} + +
+
${formatDescription(s.description)}
+ ${rows ? `${rows}
HitLv ${state.level}
` : ''} +
+
`; + }); + return `
+ + Source: encore.moe +
+ ${cards.join('') || '

No skills listed for this character.

'}`; +} + +// --- Rotations ---------------------------------------------------------------- + +function teamRow(t, idx, showMain) { + const video = safeUrl(t.video); + const calc = safeUrl(t.calc_sheet); + const hasTranscript = !!safeUrl(t.transcript); + const best = teamRow.best || 1; + const pct = Math.max(4, Math.round(((t.dps || 0) / best) * 100)); + return `
+
+
${esc(showMain ? t.team : t.team.replace(/\s*\([^)]*\)\s*$/, ''))}
+
+ ${t.style ? `${esc(t.style)}` : ''} + ${t.setup ? `${esc(t.setup)}` : ''} + ${t.author ? `by ${esc(t.author)}` : ''} +
+
+
+
${fmtNum(t.dps)}DPS
+
+ ${hasTranscript ? `` : ''} + ${video ? `Video` : ''} + ${calc ? `Calc` : ''} +
+ +
`; +} + +function renderRotations(c) { + const { main, featured } = rotationsFor(c.name); + state.visibleTeams = [...(main ? main.teams : []), ...featured]; + const all = state.visibleTeams; + teamRow.best = Math.max(1, ...all.map((t) => t.dps || 0)); + let html = ''; + if (main) { + html += `

${esc(c.name)} teams

`; + if (main.thoughts) html += `
${esc(main.thoughts)}
`; + html += main.teams.map((t, i) => teamRow(t, i, false)).join(''); + } + if (featured.length) { + const offset = main ? main.teams.length : 0; + html += `

Supporting in other teams

`; + html += featured.map((t, i) => teamRow(t, offset + i, true)).join(''); + } + if (!html) html = `

The rotation sheet has no teams with ${esc(c.name)} yet.

`; + const src = safeUrl(state.rotationsSource); + return `${html}

Teams, DPS and rotation transcripts from ${src ? `AntoCrasher's calc compilation` : "AntoCrasher's calc compilation"} (2-minute fight). The "Combo" button shows the move-by-move rotation.

`; +} + +function renderTranscript(t, tr) { + const gloss = { ...DEFAULT_GLOSSARY, ...(tr.glossary || {}) }; + const conv = rotationToInputs(tr, t.members); + const sections = tr.sections + .map((sec) => { + const steps = sec.steps + .map((st) => { + const moves = st.moves + .map((m) => { + const base = m.toLowerCase().replace(/\d+$/, ''); + const tip = gloss[m.toLowerCase()] || gloss[base] || ''; + const swap = /^(swap|outro|intro)$/i.test(m) ? ' swap' : ''; + return `${esc(m)}`; + }) + .join('›'); + return `
${esc(st.character)}${moves}
`; + }) + .join(''); + return `${sec.name ? `

${esc(sec.name)}

` : ''}${steps}`; + }) + .join(''); + const glossary = Object.entries(gloss) + .map(([k, v]) => `${esc(k)} ${esc(v)}`) + .join(''); + const unmapped = conv.unmapped.length + ? `

Not converted (no single key): ${conv.unmapped.map((u) => `${esc(u)}`).join(' ')}

` + : ''; + return `${sections || '

This transcript had no move lines.

'} + ${glossary ? `
Abbreviations
${glossary}
` : ''} +
+ + + ${unmapped} +
+ + + Open transcript +
+
`; +} + +async function toggleTranscript(teamEl) { + const t = state.visibleTeams[Number(teamEl.dataset.idx)]; + const box = teamEl.querySelector('.transcript'); + if (!box.classList.contains('hidden')) { + box.classList.add('hidden'); + return; + } + box.classList.remove('hidden'); + if (!state.transcripts.has(t.transcript)) { + box.innerHTML = '
Loading rotation…
'; + try { + state.transcripts.set(t.transcript, await api(`transcript?url=${encodeURIComponent(t.transcript)}`)); + } catch (e) { + box.innerHTML = `
Couldn't load the transcript: ${esc(e.message)}
`; + return; + } + } + box.innerHTML = renderTranscript(t, state.transcripts.get(t.transcript)); +} + +function saveAsCombo(teamEl) { + const t = state.visibleTeams[Number(teamEl.dataset.idx)]; + const inputs = teamEl.querySelector('.as-inputs textarea').value; + const suggested = `${t.team}${t.author ? ` (${t.author})` : ''}`; + const name = prompt('Save this rotation as a combo named:', suggested); + if (!name || !state.socket) return; + state.socket.send(JSON.stringify({ + type: 'save_combo', + name, + inputs, + demo_video: safeUrl(t.video), + target_game: 'wuthering_waves', + as_new: true, + })); + showNotice(`Saved "${name}". Open the Combo Tracker to practice it and add timings.`, 'ok'); +} + +// --------------------------------------------------------------------------- +// Events + boot +// --------------------------------------------------------------------------- + +function bindEvents() { + $('search').addEventListener('input', (e) => { + state.query = e.target.value.trim(); + renderRoster(); + }); + $('elementFilter').addEventListener('click', (e) => { + const b = e.target.closest('.chip'); + if (!b) return; + state.element = b.dataset.el; + renderFilters(); + renderRoster(); + }); + $('roster').addEventListener('click', (e) => { + const b = e.target.closest('.char'); + if (b) selectCharacter(Number(b.dataset.id)); + }); + $('detail').addEventListener('click', (e) => { + const tab = e.target.closest('[data-tab]'); + if (tab) { + state.tab = tab.dataset.tab; + renderDetail(); + return; + } + const act = e.target.closest('[data-act]'); + if (!act) return; + const teamEl = act.closest('.team'); + if (act.dataset.act === 'transcript') toggleTranscript(teamEl); + else if (act.dataset.act === 'copy') { + const ta = teamEl.querySelector('.as-inputs textarea'); + navigator.clipboard?.writeText(ta.value).then( + () => showNotice('Inputs copied. Paste them into a combo in the tracker.', 'ok'), + () => { ta.select(); document.execCommand('copy'); } + ); + } else if (act.dataset.act === 'save') saveAsCombo(teamEl); + }); + $('detail').addEventListener('change', (e) => { + if (e.target.id === 'levelSelect') { + state.level = Number(e.target.value); + renderDetail(); + } + }); + $('refreshBtn').addEventListener('click', () => load(true)); + window.addEventListener('hashchange', () => { + const id = Number(location.hash.slice(1)); + if (id && id !== state.selectedId) selectCharacter(id, { pushHash: false }); + }); +} + +async function load(refresh = false) { + showNotice(refresh ? 'Downloading fresh data…' : ''); + if (refresh) { + state.kits.clear(); + state.transcripts.clear(); + } + const [roster, rotations] = await Promise.allSettled([api('roster', refresh), api('rotations', refresh)]); + if (roster.status === 'rejected') { + showNotice(`Couldn't load the character list: ${roster.reason.message}`, 'warn'); + return; + } + state.roster = roster.value.characters; + if (rotations.status === 'fulfilled') { + state.rotations = rotations.value.groups; + state.rotationsSource = rotations.value.source; + } else { + showNotice(`Couldn't load team rotations: ${rotations.reason.message}`, 'warn'); + } + if (refresh && roster.status === 'fulfilled' && rotations.status === 'fulfilled' && !roster.value.stale) { + showNotice('Data refreshed.', 'ok'); + } + renderRoster(); + const id = Number(location.hash.slice(1)) || state.selectedId; + if (id) selectCharacter(id, { pushHash: false }); +} + +renderFilters(); +bindEvents(); +connectTracker(); +load(); diff --git a/static/index.html b/static/index.html index d264917..9d2f2cb 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ - Combo Trainer + WuWa Combo Tracker @@ -136,21 +136,14 @@

Combo Input

- +
-

Target game

+

Team & Characters

- - - - - +
Key images
@@ -284,6 +325,7 @@

Attempt Log

+ diff --git a/static/keys.css b/static/keys.css new file mode 100644 index 0000000..d4eb147 --- /dev/null +++ b/static/keys.css @@ -0,0 +1,86 @@ +/* Transparent by default so it composites cleanly as an OBS Browser Source. */ +:root { + --key-bg: rgba(20, 22, 28, 0.82); + --key-border: #4a4f5c; + --key-text: #e8eaf0; + --key-role: #9aa0ae; + --key-down-bg: #f2c14e; + --key-down-text: #16181d; + --key-size: 64px; +} + +html, body { + margin: 0; + background: transparent; + font-family: system-ui, sans-serif; + user-select: none; +} + +.kb { + display: inline-flex; + flex-direction: column; + gap: 6px; + padding: 8px; +} + +.kb-row { + display: flex; + gap: 6px; +} + +.gap { width: calc(var(--key-size) / 2); } + +.key { + position: relative; + width: var(--key-size); + height: var(--key-size); + box-sizing: border-box; + border: 2px solid var(--key-border); + border-radius: 8px; + background: var(--key-bg); + color: var(--key-text); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + transition: background 60ms, transform 60ms; +} + +.key.wide { width: calc(var(--key-size) * 1.6); } +.key.space { width: calc(var(--key-size) * 3.4); } +.key.mouse { border-radius: 18px 18px 10px 10px; } + +.cap { font-size: 18px; font-weight: 700; } +.role { + max-width: 100%; + padding: 0 3px; + box-sizing: border-box; + font-size: 10px; + color: var(--key-role); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portrait { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 6px; + opacity: 0.35; + pointer-events: none; +} + +.key.down { + background: var(--key-down-bg); + border-color: var(--key-down-bg); + color: var(--key-down-text); + transform: translateY(2px); +} +.key.down .role { color: var(--key-down-text); } +.key.down .portrait { opacity: 0.2; } + +.key.offline { opacity: 0.4; } diff --git a/static/keys.html b/static/keys.html new file mode 100644 index 0000000..6babb3e --- /dev/null +++ b/static/keys.html @@ -0,0 +1,43 @@ + + + + + + + WuWa Key Overlay + + + + + +
+
+
1Slot 1
+
2Slot 2
+
3Slot 3
+
+
LMBAttack
+
RMBDodge
+
+
+
QEcho
+
W
+
ESkill
+
RLiberation
+
+
FTune Break
+
+
+
A
+
S
+
D
+
+
+
ShiftSprint
+
SpaceJump
+
+
+ + + + diff --git a/static/keys.js b/static/keys.js new file mode 100644 index 0000000..8f55649 --- /dev/null +++ b/static/keys.js @@ -0,0 +1,60 @@ +// Live key overlay: lights up keys as the backend reports them (type "key_input"), +// and labels 1/2/3 with the selected team's characters. +const WS_URL = 'ws://localhost:8765'; +const keyEls = new Map(Array.from(document.querySelectorAll('.key')).map((el) => [el.dataset.key, el])); +let reconnectDelayMs = 500; + +function setDown(key, down) { + const el = keyEls.get(key); + if (el) el.classList.toggle('down', !!down); +} + +function releaseAll() { + keyEls.forEach((el) => el.classList.remove('down')); +} + +/** Show character names (and portraits) of the selected team on the swap keys. */ +function applyTeam(editor) { + if (!editor || !editor.ww_team_slots) return; + const chars = new Map((editor.ww_characters || []).map((c) => [c.name_key, c])); + ['1', '2', '3'].forEach((slot) => { + const el = keyEls.get(slot); + if (!el) return; + const ch = chars.get(editor.ww_team_slots[`slot${slot}`] || ''); + el.querySelector('.role').textContent = ch ? ch.name : `Slot ${slot}`; + el.querySelector('.portrait')?.remove(); + const img = (ch && ch.swap_image) || ''; + if (/^https?:\/\//i.test(img)) { + const pic = document.createElement('img'); + pic.className = 'portrait'; + pic.src = img; + pic.alt = ''; + pic.referrerPolicy = 'no-referrer'; + pic.onerror = () => pic.remove(); + el.prepend(pic); + } + }); +} + +function connect() { + const socket = new WebSocket(WS_URL); + socket.onopen = () => { + reconnectDelayMs = 500; + keyEls.forEach((el) => el.classList.remove('offline')); + }; + socket.onclose = () => { + releaseAll(); + keyEls.forEach((el) => el.classList.add('offline')); + setTimeout(connect, reconnectDelayMs); + reconnectDelayMs = Math.min(reconnectDelayMs * 2, 5000); + }; + socket.onmessage = (event) => { + let msg; + try { msg = JSON.parse(event.data); } catch (_) { return; } + if (msg.type === 'key_input') setDown(msg.key, msg.down); + else if (msg.type === 'init') applyTeam(msg.editor); + else if (msg.type === 'combo_data') applyTeam(msg); + }; +} + +connect(); diff --git a/static/style.css b/static/style.css index 29731c6..673d63a 100644 --- a/static/style.css +++ b/static/style.css @@ -418,6 +418,16 @@ button.danger:hover { border-radius: var(--border-radius); } +.combo-steps-controls a.icon-btn { + background: var(--accent-blue); + color: var(--bg-primary); + transition: var(--transition); +} + +.combo-steps-controls a.icon-btn:hover { + background: #5aaaff; +} + .combo-steps-controls .icon-btn svg { flex-shrink: 0; } @@ -1007,6 +1017,43 @@ button.danger:hover { margin-bottom: 0; } +/* Compact stat cards */ +.stat-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px; +} + +.stat-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border: 1px solid var(--accent-grey); + border-radius: var(--border-radius); + background: var(--bg-primary); + min-width: 0; +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stat-value { + font-size: 1.05rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stat-card:not([class*="diff-"]) .stat-value { + color: var(--text-primary); +} + .time-display, .stats-display { color: var(--text-secondary); @@ -2073,3 +2120,104 @@ body.timeline-window-view #copyOverlayUrlBtn, body.timeline-window-view #openTimelineWindowBtn { display: none !important; } + +/* Wuthering Waves move name under each step (see ww_moves.js) */ +.step-move { + max-width: 100%; + margin-top: 3px; + font-family: system-ui, sans-serif; + font-size: 10px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* "Add from wuthering.gg" row in the Characters panel */ +.ww-char-sync { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 0 0 12px 0; +} + +.ww-char-sync input { + flex: 1 1 220px; +} + +.ww-char-sync button.subtle { + background: rgba(255, 255, 255, 0.12); + color: var(--text-primary); +} + +.ww-char-sync .ww-moves-link { + color: var(--accent-blue); + text-decoration: none; + white-space: nowrap; +} + +.ww-char-sync .ww-moves-link:hover { + text-decoration: underline; +} + +/* Color legend (info button above Combo Steps). Swatches reuse the timeline's colors. */ +.legend-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px 24px; + margin: 0 0 14px 0; + padding: 12px 14px; + border: 1px solid var(--accent-grey); + border-radius: var(--border-radius); + background: var(--bg-secondary); + font-size: 0.85rem; +} + +.legend-group h4 { + margin: 0 0 6px 0; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0; +} + +.legend-swatch, +.legend-key { + flex: 0 0 auto; + width: 30px; + height: 18px; + box-sizing: border-box; + border-radius: 4px; + background: var(--bg-primary); +} + +.legend-key { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--accent-grey); + font-size: 10px; + font-weight: 800; + color: var(--text-secondary); +} + +.sw-press { border: 2px solid #444; } +.sw-wait { border: 2px solid var(--accent-yellow); background: linear-gradient(to right, rgba(255, 221, 74, 0.5) 50%, var(--bg-primary) 50%); } +.sw-short { border: 2px solid rgba(255, 221, 74, 0.35); } +.sw-hold { border: 2px solid var(--accent-blue); background: linear-gradient(to right, rgba(74, 255, 136, 0.9) 50%, var(--bg-primary) 50%); } +.sw-optional { border: 2px dashed var(--text-secondary); } +.sw-group { border: 2px solid rgba(200, 200, 200, 0.65); } +.sw-sequence { border: 2px solid rgba(138, 43, 226, 0.65); background: rgba(138, 43, 226, 0.08); } +.sw-active { border: 2px solid var(--accent-blue); box-shadow: 0 0 8px rgba(74, 158, 255, 0.5); } +.sw-done { border: 2px solid var(--accent-green); background: rgba(74, 255, 136, 0.1); } +.sw-miss { border: 2px solid var(--accent-red); background: rgba(255, 74, 74, 0.14); } + +.legend-panel.hidden { display: none; } diff --git a/static/ww_moves.js b/static/ww_moves.js new file mode 100644 index 0000000..71699d4 --- /dev/null +++ b/static/ww_moves.js @@ -0,0 +1,107 @@ +// Wuthering Waves move names for timeline steps. +// +// Inputs are decoded by rule, walking the timeline in order: +// leading f = start the fight (ToA prompt), any later f = Tune Break +// 1/2/3 = swap in that slot (Intro) +// lmb = Basic 1, 2, 3... (reset by any other action or a wait of 1s+; wraps at the chain length) +// lmb right after rmb = Dodge Counter?, right after space = Mid-air Attack +// hold(lmb) = Heavy, or another Basic hit for characters whose hold continues the chain +// e / hold(e) = Skill / Held Skill, q = Echo, r = Liberation, rmb = Dodge, space = Jump, shift = Sprint + +const WW_SLOTS = ['1', '2', '3']; + +// Per-character Normal Attack rules from each kit's text on encore.moe (see wuwa-research/move-rules.md). +// basic = hits in the Basic chain before it restarts; hold = what hold(lmb) does: +// a Heavy Attack name, or null when holding just continues the Basic chain. +const WW_CHARACTER_RULES = { + zani: { basic: 4, hold: 'Heavy' }, + phoebe: { basic: 3, hold: 'Heavy' }, + pheobe: { basic: 3, hold: 'Heavy' }, + aemeath: { basic: 4, hold: 'Heavy Charged' }, + lynae: { basic: 3, hold: null }, + mornye: { basic: 4, hold: 'Heavy' }, + augusta: { basic: 4, hold: 'Steelclash' }, + iuno: { basic: 3, hold: 'Heavy' }, + shorekeeper: { basic: 4, hold: 'Heavy' }, + hiyuki: { basic: 3, hold: null }, + lucilla: { basic: 3, hold: 'Heavy' }, + suisui: { basic: 4, hold: null }, +}; +const WW_DEFAULT_RULE = { basic: 0, hold: 'Heavy' }; // basic 0 = unknown chain length, never wraps + +// The in-game Basic chain resets after a pause this long. +const WW_BASIC_RESET_MS = 1000; + +/** Key a timeline step acts on, or '' for a plain wait. */ +function wwStepKey(step) { + if (!step) return ''; + if (step.type === 'wait') { + return step.mode === 'mandatory' ? (step.wait_for || '').toString().toLowerCase() : ''; + } + return (step.input || '').toString().toLowerCase(); +} + +/** + * Returns label(step, slot) that names each step's move. Call it once per step, in timeline order. + * slotNames maps '1'/'2'/'3' to character names; slot is the active character's slot. + */ +function createWwMoveLabeler(slotNames) { + let fightStarted = false; + let basicCount = 0; + let lastKey = ''; + const who = (slot) => (slotNames && slotNames[slot]) || `Slot ${slot}`; + const rulesFor = (slot) => WW_CHARACTER_RULES[who(slot).toLowerCase()] || WW_DEFAULT_RULE; + + // Advance the Basic chain by `hits`, wrapping at the character's chain length. + const basicLabel = (n, rule, hits) => { + const stage = (i) => (rule.basic > 0 ? ((i - 1) % rule.basic) + 1 : i); + const first = stage(basicCount + 1); + basicCount += hits; + const last = stage(basicCount); + return hits > 1 ? `${n} Basic ${first}-${last}` : `${n} Basic ${first}`; + }; + + return function label(step, slot) { + const key = wwStepKey(step); + if (!key) { + if (step && step.type === 'wait' && Number(step.duration) >= WW_BASIC_RESET_MS) basicCount = 0; + return ''; + } + + const isHold = step.type === 'hold' || step.type === 'hold_with_body'; + const s = slot || '1'; + const n = who(s); + const prevKey = lastKey; + const wasStarted = fightStarted; + lastKey = key; + fightStarted = true; + + if (key === 'lmb') { + const rule = rulesFor(s); + if (step.type === 'spam') { basicCount = 0; return `${n} Basic spam`; } + if (isHold && rule.hold) { basicCount = 0; return `${n} ${rule.hold}`; } + if (!isHold && prevKey === 'space') { basicCount = 0; return `${n} Mid-air Attack`; } + // A dodge can't be told from a perfect dodge by keys alone, hence the "?". + const hits = Math.max(1, Number(step.chain_count) || 1); + if (!isHold && prevKey === 'rmb') { + basicCount = 0; + return hits > 1 ? `${n} Dodge Counter? +${hits - 1}` : `${n} Dodge Counter?`; + } + const text = basicLabel(n, rule, hits); + return isHold ? `${text} (held)` : text; + } + + basicCount = 0; + if (key === 'f') return wasStarted ? 'Tune Break' : 'Start fight'; + if (WW_SLOTS.includes(key)) return `${who(key)} Intro`; + if (key === 'e') return isHold ? `${n} Held Skill` : `${n} Skill`; + if (key === 'q') return `${n} Echo`; + if (key === 'r') return `${n} Liberation`; + if (key === 'rmb') return 'Dodge'; + if (key === 'space') return 'Jump'; + if (key === 'shift') return 'Sprint'; + return ''; + }; +} + +if (typeof module !== 'undefined') module.exports = { createWwMoveLabeler, wwStepKey }; diff --git a/static/ww_rotations.js b/static/ww_rotations.js new file mode 100644 index 0000000..c923ebd --- /dev/null +++ b/static/ww_rotations.js @@ -0,0 +1,113 @@ +// Turn a community rotation transcript into ComboTracker inputs. +// +// Transcripts (AntoCrasher's rotation hub) read like +// phoebe: eskill > lib > skill > echo > dash > ha > swap +// rover: ba23 > ha23 > echo > lib > fskill > swap +// Moves map to Wolf's keys: ba = lmb per stage digit, ha = hold(lmb), (e/f)skill = e, lib = r, +// echo = q, dash = rmb, jump = space, tbs = f, and swap/outro = the next character's slot key. +// Timings aren't in the transcripts, so the result has no waits; anything unknown is skipped and reported. + +const WW_HOLD_LMB = 'hold(lmb, 0.5)'; + +// [pattern, keys for one occurrence]; a trailing digit run like "ba123" repeats the keys once per digit. +const WW_ROTATION_MOVES = [ + [/^[a-z]?ba$/, ['lmb']], // ba, fba (forte), uba (ultimate), eba... + [/^(n|b)\d?$/, ['lmb']], // n1 / b2 style stage notation + [/^[a-z]?ha$/, [WW_HOLD_LMB]], // ha, fha + [/^(heavy|charged)$/, [WW_HOLD_LMB]], + [/^[a-z]?skill$/, ['e']], // skill, eskill, fskill, skill2 + [/^(e|res)$/, ['e']], + [/^(lib|ult|liberation|r)$/, ['r']], + [/^(echo|q)$/, ['q']], + [/^(dash|dodge)$/, ['rmb']], + [/^(jump)$/, ['space']], + [/^(plunge|mid-?air|midair)$/, ['space', 'lmb']], + [/^(dc|dodgecounter|dodge counter)$/, ['rmb', 'lmb']], + [/^(tbs|tunebreak|tune break|tb)$/, ['f']], +]; + +function wwNormalizeMove(move) { + return String(move || '') + .toLowerCase() + .replace(/\(.*?\)/g, '') // "(optional)" notes + .replace(/\s*x\s*\d+$/, '') // "ba x2" is handled below via count + .trim(); +} + +// "ba123" -> { base: "ba", count: 3 }, "skill2" -> { base: "skill", count: 1 }, "ba x2" -> count 2 +function wwSplitMove(move) { + const raw = String(move || '').toLowerCase(); + const times = raw.match(/\s*x\s*(\d+)\s*$/); + const norm = wwNormalizeMove(move); + const m = norm.match(/^(.*?)(\d*)$/); + let base = m ? m[1].trim() : norm; + const digits = m ? m[2] : ''; + let count = 1; + // Stage digits only repeat attacks; "skill2" means the second skill press, one press. + if (digits && /^[a-z]?(ba|ha)$/.test(base)) count = digits.length; + if (times) count *= parseInt(times[1], 10); + return { base, count }; +} + +function wwMoveKeys(move) { + const { base, count } = wwSplitMove(move); + for (const [re, keys] of WW_ROTATION_MOVES) { + if (re.test(base)) { + const out = []; + for (let i = 0; i < count; i++) out.push(...keys); + return out; + } + } + return null; +} + +// Team member names -> slot key lookup ("Rover: Spectro" and "rover" both match "Rover"). +function wwSlotFinder(members) { + const slots = (members || []).map((m, i) => ({ + key: String(i + 1), + name: String(m || '').toLowerCase().split(/[:\s]/)[0], + })); + return (character) => { + const c = String(character || '').toLowerCase().split(/[:\s]/)[0]; + const hit = slots.find((s) => s.name && c && (s.name.startsWith(c) || c.startsWith(s.name))); + return hit ? hit.key : ''; + }; +} + +// transcript: {sections: [{name, steps: [{character, moves: [..]}]}]}; members: team order (slot 1..3) +function rotationToInputs(transcript, members) { + const slotOf = wwSlotFinder(members); + const steps = []; + for (const sec of (transcript && transcript.sections) || []) { + for (const st of sec.steps || []) steps.push(st); + } + const keys = []; + const unmapped = new Set(); + let active = slotOf(members && members[0]) || '1'; + + const swapTo = (character) => { + const slot = slotOf(character); + if (!slot) { + unmapped.add(`swap to ${character}`); + return; + } + if (slot !== active) { + keys.push(slot); + active = slot; + } + }; + + for (const st of steps) { + swapTo(st.character); // covers the opener and transcripts that omit "swap" + for (const move of st.moves || []) { + const base = wwNormalizeMove(move); + if (base === 'swap' || base === 'outro' || base === 'intro') continue; // done by the next step's swapTo + const k = wwMoveKeys(move); + if (k) keys.push(...k); + else unmapped.add(move); + } + } + return { inputs: keys.join(', '), unmapped: [...unmapped] }; +} + +if (typeof module !== 'undefined') module.exports = { rotationToInputs, wwMoveKeys, wwSlotFinder }; diff --git a/tests/test_combo_commands.py b/tests/test_combo_commands.py new file mode 100644 index 0000000..cd6fa06 --- /dev/null +++ b/tests/test_combo_commands.py @@ -0,0 +1,125 @@ +import unittest + +from _combo_commands import per_combo_maps +from combo_engine import ComboTrackerEngine +from state_store import MemoryStateStore + + +def _save(engine, name, inputs, **extra): + return engine.save_or_update_combo( + name=name, + inputs=inputs, + enders=extra.pop("enders", ""), + expected_time="", + user_difficulty="", + step_display_mode="", + key_images=None, + demo_video="", + ww_team_id="", + **extra, + ) + + +def _engine_with(*combos): + engine = ComboTrackerEngine(state_store=MemoryStateStore()) + for name, inputs in combos: + engine.new_combo() + _save(engine, name, inputs) + return engine + + +class ComboRenameTests(unittest.TestCase): + def test_rename_onto_existing_combo_is_refused(self): + engine = _engine_with(("A", "e, q"), ("B", "r, r, r")) + engine.set_active_combo("A") + + ok, err = _save(engine, "B", "e, q") + + self.assertFalse(ok) + self.assertIn("already exists", err) + self.assertEqual(engine.combos["A"], ["e", "q"]) + self.assertEqual(engine.combos["B"], ["r", "r", "r"]) + + def test_new_combo_with_taken_name_is_refused(self): + engine = _engine_with(("B", "r, r, r")) + engine.new_combo() + + ok, _ = _save(engine, "B", "q") + + self.assertFalse(ok) + self.assertEqual(engine.combos["B"], ["r", "r", "r"]) + + def test_rename_to_new_name_moves_combo(self): + engine = _engine_with(("A", "e, q")) + engine.set_active_combo("A") + + ok, _ = _save(engine, "C", "e, q") + + self.assertTrue(ok) + self.assertNotIn("A", engine.combos) + self.assertEqual(engine.combos["C"], ["e", "q"]) + self.assertIn("C", engine.combo_stats) + + def test_updating_active_combo_keeps_its_name(self): + engine = _engine_with(("A", "e, q")) + engine.set_active_combo("A") + + ok, _ = _save(engine, "A", "e, q, r") + + self.assertTrue(ok) + self.assertEqual(engine.combos["A"], ["e", "q", "r"]) + + +class PerComboDataTests(unittest.TestCase): + def test_every_per_combo_dict_is_listed(self): + engine = _engine_with(("A", "e, q")) + listed = {id(m) for m in per_combo_maps(engine)} + # Engine dicts named combo_* are keyed by combo name, except the global ender settings. + for attr, value in vars(engine).items(): + if attr.startswith("combo_") and isinstance(value, dict) and not attr.startswith("combo_enders"): + self.assertIn(id(value), listed, attr) + + def test_rename_carries_stats(self): + engine = _engine_with() + engine.new_combo() + _save(engine, "A", "e, q") + engine.combo_stats["A"]["success"] = 3 + + ok, _ = _save(engine, "C", "e, q") + + self.assertTrue(ok) + self.assertEqual(engine.combo_stats["C"]["success"], 3) + self.assertNotIn("A", engine.combo_stats) + + def test_delete_clears_every_per_combo_dict(self): + engine = _engine_with(("A", "e, q")) + engine.combo_expected_ms["A"] = 1000 + engine.ww.combo_ww_team["A"] = "team1" + + ok, _ = engine.delete_combo("A") + + self.assertTrue(ok) + for mapping in per_combo_maps(engine): + self.assertNotIn("A", mapping) + + +class SaveAsNewTests(unittest.TestCase): + """The Characters page saves rotations while another combo is active in the tracker.""" + + def test_as_new_leaves_active_combo_and_enders_alone(self): + engine = _engine_with(("A", "e, q")) + engine.set_active_combo("A") + _save(engine, "A", "e, q", enders="1:0.5s, r") + enders_before = dict(engine.combo_enders) + self.assertEqual(enders_before, {"1": 500, "r": 0}) + + ok, _ = _save(engine, "Rotation", "1, e, r", enders=None, as_new=True) + + self.assertTrue(ok) + self.assertEqual(engine.combos["A"], ["e", "q"]) + self.assertEqual(engine.combos["Rotation"], ["1", "e", "r"]) + self.assertEqual(engine.combo_enders, enders_before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_state_store.py b/tests/test_state_store.py index 3276773..bce5a06 100644 --- a/tests/test_state_store.py +++ b/tests/test_state_store.py @@ -44,6 +44,18 @@ def test_save_rotates_valid_primary_and_loads_latest(self): ) self.assertEqual(list(path.parent.glob("*.tmp")), []) + def test_seed_is_used_only_when_no_save_exists(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "combos.json" + seed = Path(directory) / "combos.example.json" + seed.write_text(json.dumps({"version": "seed"}), encoding="utf-8") + store = JsonStateStore(path, seed_path=seed) + + self.assertEqual(store.load(), {"version": "seed"}) + store.save({"version": 1}) + self.assertEqual(store.load(), {"version": 1}) + self.assertEqual(json.loads(seed.read_text(encoding="utf-8")), {"version": "seed"}) + def test_corrupt_primary_falls_back_to_known_good_backup(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "combos.json" @@ -73,3 +85,21 @@ def test_corrupt_primary_is_not_rotated_over_backup(self): if __name__ == "__main__": unittest.main() + + +class WutheringWavesDefaultsTests(unittest.TestCase): + def test_fresh_install_gets_ww_enders_and_transcribe_keys(self): + engine = ComboTrackerEngine(state_store=MemoryStateStore()) + self.assertEqual(engine.combo_enders.get("r"), 2000) + self.assertIn("space", engine.combo_enders_soft) + self.assertIn("lmb", engine.transcribe_valid_keys) + + def test_saved_empty_enders_are_kept(self): + engine = ComboTrackerEngine(state_store=MemoryStateStore({"combo_enders": {}, "combo_enders_soft": []})) + self.assertEqual(engine.combo_enders, {}) + + def test_legacy_generic_combos_still_load(self): + data = {"combos": {"old": ["e", "q"]}, "combo_target_game": {"old": "generic"}} + engine = ComboTrackerEngine(state_store=MemoryStateStore(data)) + self.assertEqual(engine.combos["old"], ["e", "q"]) + self.assertEqual(engine.get_editor_payload()["target_game"], "wuthering_waves") diff --git a/tests/test_ww_ender_policy.py b/tests/test_ww_ender_policy.py index fda1510..45ea17b 100644 --- a/tests/test_ww_ender_policy.py +++ b/tests/test_ww_ender_policy.py @@ -1,114 +1,102 @@ -"""Unit tests for Wuthering Waves ender policy (can_ender_drop_combo, on_accepted_key).""" -import unittest - -from Game_Wuthering_Waves import WutheringWavesGame - - -class MockEngine: - """Minimal engine duck-type for WW policy tests.""" - def __init__(self): - self.active_combo_name = "test_combo" - self.last_input_time = None - self.current_index = 0 - self._combo_enders = set() - self._ender_cooldown_until = {} - - def _is_combo_ender(self, input_name: str) -> bool: - return (input_name or "").strip().lower() in self._combo_enders - +"""Unit tests for Wuthering Waves ender policy (can_ender_drop_combo, on_accepted_key).""" +import unittest + +from Game_Wuthering_Waves import WutheringWavesGame + + +class MockEngine: + """Minimal engine duck-type for WW policy tests.""" + def __init__(self): + self.active_combo_name = "test_combo" + self.last_input_time = None + self.current_index = 0 + self._combo_enders = set() + self._ender_cooldown_until = {} + + def _is_combo_ender(self, input_name: str) -> bool: + return (input_name or "").strip().lower() in self._combo_enders + def _ender_on_cooldown(self, input_name: str, *, now: float | None = None) -> bool: - key = (input_name or "").strip().lower() - if not key or key not in self._combo_enders: - return False + key = (input_name or "").strip().lower() + if not key or key not in self._combo_enders: + return False until = self._ender_cooldown_until.get(key, 0.0) if now is None: import time now = time.perf_counter() return now <= until - - -class TestCanEnderDropCombo(unittest.TestCase): - def test_empty_input_returns_false(self): - ww = WutheringWavesGame() - engine = MockEngine() - engine._combo_enders = {"e"} - self.assertFalse(ww.can_ender_drop_combo(engine, "")) - self.assertFalse(ww.can_ender_drop_combo(engine, " ")) - - def test_non_ender_returns_false(self): - ww = WutheringWavesGame() - engine = MockEngine() - engine._combo_enders = {"e"} - engine.last_input_time = 1.0 - self.assertFalse(ww.can_ender_drop_combo(engine, "q")) - self.assertFalse(ww.can_ender_drop_combo(engine, "r")) - - def test_no_input_yet_and_index_zero_returns_false(self): - ww = WutheringWavesGame() - engine = MockEngine() - engine._combo_enders = {"e"} - engine.last_input_time = None - engine.current_index = 0 - self.assertFalse(ww.can_ender_drop_combo(engine, "e")) - - def test_ender_after_input_returns_true_for_generic(self): - ww = WutheringWavesGame() - ww.combo_target_game["test_combo"] = "generic" - engine = MockEngine() - engine._combo_enders = {"e"} - engine.last_input_time = 1.0 - engine.current_index = 2 - self.assertTrue(ww.can_ender_drop_combo(engine, "e")) - - def test_ww_current_character_slot_does_not_drop(self): - ww = WutheringWavesGame() - ww.combo_target_game["test_combo"] = "wuthering_waves" - ww.ww_active_character = "2" - engine = MockEngine() - engine._combo_enders = {"1", "2", "3"} - engine.last_input_time = 1.0 - engine.current_index = 2 - self.assertFalse(ww.can_ender_drop_combo(engine, "2")) - self.assertTrue(ww.can_ender_drop_combo(engine, "1")) - self.assertTrue(ww.can_ender_drop_combo(engine, "3")) - - def test_ww_other_character_slot_drops(self): - ww = WutheringWavesGame() - ww.combo_target_game["test_combo"] = "wuthering_waves" - ww.ww_active_character = "2" - engine = MockEngine() - engine._combo_enders = {"1", "2", "3"} - engine.last_input_time = 1.0 - engine.current_index = 2 - self.assertTrue(ww.can_ender_drop_combo(engine, "1")) - self.assertTrue(ww.can_ender_drop_combo(engine, "3")) - - -class TestOnAcceptedKey(unittest.TestCase): - def test_generic_game_does_nothing(self): - ww = WutheringWavesGame() - ww.combo_target_game["test_combo"] = "generic" - engine = MockEngine() - ww.on_accepted_key(engine, "2") - self.assertIsNone(ww.ww_active_character) - - def test_ww_game_sets_character_slot(self): - ww = WutheringWavesGame() - ww.combo_target_game["test_combo"] = "wuthering_waves" - engine = MockEngine() - ww.on_accepted_key(engine, "2") - self.assertEqual(ww.ww_active_character, "2") - ww.on_accepted_key(engine, "1") - self.assertEqual(ww.ww_active_character, "1") - - def test_non_slot_key_does_not_clear_character(self): - ww = WutheringWavesGame() - ww.combo_target_game["test_combo"] = "wuthering_waves" - ww.ww_active_character = "2" - engine = MockEngine() - ww.on_accepted_key(engine, "e") - self.assertEqual(ww.ww_active_character, "2") - - -if __name__ == "__main__": - unittest.main() + + +class TestCanEnderDropCombo(unittest.TestCase): + def test_empty_input_returns_false(self): + ww = WutheringWavesGame() + engine = MockEngine() + engine._combo_enders = {"e"} + self.assertFalse(ww.can_ender_drop_combo(engine, "")) + self.assertFalse(ww.can_ender_drop_combo(engine, " ")) + + def test_non_ender_returns_false(self): + ww = WutheringWavesGame() + engine = MockEngine() + engine._combo_enders = {"e"} + engine.last_input_time = 1.0 + self.assertFalse(ww.can_ender_drop_combo(engine, "q")) + self.assertFalse(ww.can_ender_drop_combo(engine, "r")) + + def test_no_input_yet_and_index_zero_returns_false(self): + ww = WutheringWavesGame() + engine = MockEngine() + engine._combo_enders = {"e"} + engine.last_input_time = None + engine.current_index = 0 + self.assertFalse(ww.can_ender_drop_combo(engine, "e")) + + def test_ender_after_input_returns_true(self): + ww = WutheringWavesGame() + engine = MockEngine() + engine._combo_enders = {"e"} + engine.last_input_time = 1.0 + engine.current_index = 2 + self.assertTrue(ww.can_ender_drop_combo(engine, "e")) + + def test_ww_current_character_slot_does_not_drop(self): + ww = WutheringWavesGame() + ww.ww_active_character = "2" + engine = MockEngine() + engine._combo_enders = {"1", "2", "3"} + engine.last_input_time = 1.0 + engine.current_index = 2 + self.assertFalse(ww.can_ender_drop_combo(engine, "2")) + self.assertTrue(ww.can_ender_drop_combo(engine, "1")) + self.assertTrue(ww.can_ender_drop_combo(engine, "3")) + + def test_ww_other_character_slot_drops(self): + ww = WutheringWavesGame() + ww.ww_active_character = "2" + engine = MockEngine() + engine._combo_enders = {"1", "2", "3"} + engine.last_input_time = 1.0 + engine.current_index = 2 + self.assertTrue(ww.can_ender_drop_combo(engine, "1")) + self.assertTrue(ww.can_ender_drop_combo(engine, "3")) + + +class TestOnAcceptedKey(unittest.TestCase): + def test_ww_game_sets_character_slot(self): + ww = WutheringWavesGame() + engine = MockEngine() + ww.on_accepted_key(engine, "2") + self.assertEqual(ww.ww_active_character, "2") + ww.on_accepted_key(engine, "1") + self.assertEqual(ww.ww_active_character, "1") + + def test_non_slot_key_does_not_clear_character(self): + ww = WutheringWavesGame() + ww.ww_active_character = "2" + engine = MockEngine() + ww.on_accepted_key(engine, "e") + self.assertEqual(ww.ww_active_character, "2") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ww_icons.py b/tests/test_ww_icons.py new file mode 100644 index 0000000..8466616 --- /dev/null +++ b/tests/test_ww_icons.py @@ -0,0 +1,72 @@ +import asyncio +import unittest +from unittest import mock + +from combo_engine import ComboTrackerEngine +from state_store import MemoryStateStore +import ww_sync +from ww_icons import character_slug, parse_character_icons + +# Trimmed from a wuthering.gg character page: paths are HTML-escaped and relative. +SAMPLE_PAGE = """ + + + + + +""" + + +class ParseIconsTests(unittest.TestCase): + def test_parses_full_icon_set(self): + icons = parse_character_icons(SAMPLE_PAGE) + self.assertEqual( + icons["swap_image"], + "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_53_UI.png", + ) + self.assertTrue(icons["lmb_image"].endswith("SP_IconNorKnife.png")) + self.assertTrue(icons["ability_images"]["e"].endswith("SP_IconAimisiB1.png")) + self.assertTrue(icons["ability_images"]["r"].endswith("SP_IconAimisiC1.png")) + self.assertTrue(icons["ability_images"]["q"].endswith("T_MstSkil_34025_UI.png")) + self.assertEqual(icons["missing"], []) + + def test_reports_missing_icons(self): + icons = parse_character_icons("nothing here") + self.assertEqual(set(icons["missing"]), {"swap_image", "lmb_image", "e", "r", "q"}) + + def test_slug(self): + self.assertEqual(character_slug(" Phrolova "), "phrolova") + self.assertEqual(character_slug("Jiyan Zhe"), "jiyan-zhe") + + +class SyncFromWebTests(unittest.TestCase): + def setUp(self): + self.engine = ComboTrackerEngine(state_store=MemoryStateStore()) + self.sent = [] + self.engine.set_emitter(self.sent.append) + + def statuses(self): + return [m["text"] for m in self.sent if m.get("type") == "status"] + + def test_adds_character_from_page(self): + with mock.patch.object(ww_sync, "fetch_character_icons", return_value=parse_character_icons(SAMPLE_PAGE)): + asyncio.run(ww_sync.sync_character(self.engine, "aemeath")) + saved = self.engine.ww.ww_characters["aemeath"] + self.assertEqual(saved["name"], "Aemeath") + self.assertTrue(saved["ability_images"]["e"].endswith("SP_IconAimisiB1.png")) + self.assertIn("Saved Aemeath with icons from wuthering.gg.", self.statuses()) + + def test_keeps_existing_icons_the_site_lacks(self): + self.engine.save_ww_character(name="Zani", swap_image="old.png", lmb_image="lmb.png", ability_images={"q": "echo.png"}) + partial = parse_character_icons(SAMPLE_PAGE.replace("T_MstSkil_34025_UI", "nope")) + with mock.patch.object(ww_sync, "fetch_character_icons", return_value=partial): + asyncio.run(ww_sync.sync_character(self.engine, "zani")) + saved = self.engine.ww.ww_characters["zani"] + self.assertEqual(saved["ability_images"]["q"], "echo.png") + self.assertTrue(saved["swap_image"].startswith("https://wuthering.gg/")) + + def test_unknown_character_reports_error(self): + with mock.patch.object(ww_sync, "fetch_character_icons", side_effect=RuntimeError("no page")): + asyncio.run(ww_sync.sync_character(self.engine, "nobody")) + self.assertNotIn("nobody", self.engine.ww.ww_characters) + self.assertIn("no page", self.statuses()) diff --git a/tests/test_ww_library.py b/tests/test_ww_library.py new file mode 100644 index 0000000..1b4df6d --- /dev/null +++ b/tests/test_ww_library.py @@ -0,0 +1,152 @@ +import json +import tempfile +import unittest +from pathlib import Path + +import ww_library +from ww_library import ( + Library, + LibraryError, + clean_description, + handle_api, + parse_kit, + parse_roster, + parse_rotation_sheet, + parse_transcript, + transcript_export_url, +) + +# Trimmed from the gviz CSV export of AntoCrasher's "2 Minute Calc Compilation" tab. +SHEET_CSV = '''"","AntoCrasher Calc - Zani Team Damage With Different Setups (2 Minutes / 4 Rot + Extension) ","Author","Extra Info","Zani","Slot 2","Slot 3","Tunebreak","Total Damage","DPS","Rotation Time","%","Video Showcase","Rotation Transcript","Calc Sheet","Last Updated","","Personal Thoughts ""Start with the 3NF quickswap rotation.""","" +"","Zani, Phoebe S0R1, Rover S6R1 (Advanced Quickswap)","Cyan","4NF","7,490,611.64","1,179,870.22","1,608,456.23","313,440.43","10,592,378.52","88,269.82","120.00","146.79%","https://www.youtube.com/watch?v=CEQWQrnbQg8","https://docs.google.com/document/d/1Bfs9xX7ZbnYPjXrfLdmBZOVBDJ2uX3q1u6D2v5sHZcQ/edit?tab=t.t9jbtmxsowwk","https://docs.google.com/spreadsheets/d/14_Lj/edit?gid=898931522","","","" +"","Zani, Phoebe S0R1, Shorekeeper S0R1 (123) ","—","2.5NF","5,556,798.55","1,181,070.87","227,631.18","250,752.34","7,216,252.94","60,135.44","120.00","100.00%","https://youtu.be/Vtl3Oh7Qa4c","","","","","" +"","AntoCrasher Calc - Cartethyia Team Damage With Different Setups (2 Minutes / 4 Rot + Extension)","","","","","","","","","","","","","","","","Personal Thoughts","For casual play, learn the optimized 123 rotation.","" +"","","Author","Extra Info","","","","","","","","","Video Showcase","Rotation Transcript","Calc Sheet","Last Updated","","","" +"","Cartethyia Solo","Eugen","DP + 3x BA5","3,984,615.86","0.00","0.00","386,715.28","4,371,331.13","36,427.76","120.00","53.43%","https://youtu.be/YxTsJuCHoXg","","","","","" +''' + +TRANSCRIPT = """AntoCrasher Rotations Hub +ba = basic attack +nf = nightfall +lib = liberation + +Zani Phoebe Rover ADV QS Cyan 4NF Transcript + +Opener + +phoebe: eskill > lib > skill > echo > dash > ha > swap +rover: ba23 > ha23 > echo > lib > fskill > swap +zani: skill > ba3 > swap +phoebe: outro +""" + +KIT = { + "Id": 1507, + "Name": "Zani", + "ElementName": "Spectro", + "RoleHeadIcon": "https://example/zani.webp", + "Skills": [ + {"SkillId": 2, "SkillType": "Resonance Liberation", "SkillName": "Rebellious Instinct", + "SkillDescribe": "Deal DMG.", "SkillAttributes": []}, + {"SkillId": 1, "SkillType": "Normal Attack", "SkillName": "Routine Negotiation", + "SkillDescribe": 'Basic Attack
Perform up to 4 attacks & more.', + "Icon": "https://example/fist.webp", + "SkillAttributes": [{"attributeName": "Stage 1 DMG", "values": ["29.60%", "32.03%"]}]}, + ], +} + + +class ParseTests(unittest.TestCase): + def test_roster_dedupes_rover_and_sorts(self): + data = {"roleList": [ + {"Id": 1507, "Name": "Zani", "Element": {"Name": "Spectro"}, "WeaponType": {"Name": "Gauntlets"}}, + {"Id": 1501, "Name": "Rover: Spectro", "Element": {"Name": "Spectro"}}, + {"Id": 1502, "Name": "Rover: Spectro", "Element": {"Name": "Spectro"}}, + ]} + chars = parse_roster(data)["characters"] + self.assertEqual([c["name"] for c in chars], ["Rover: Spectro", "Zani"]) + self.assertEqual(chars[1]["weapon"], "Gauntlets") + + def test_kit_orders_skills_and_cleans_text(self): + kit = parse_kit(KIT) + self.assertEqual([s["type"] for s in kit["skills"]], ["Normal Attack", "Resonance Liberation"]) + na = kit["skills"][0] + self.assertEqual(na["key"], "LMB") + self.assertEqual(na["description"], "**Basic Attack**\nPerform up to 4 attacks & more.") + self.assertEqual(na["multipliers"][0], {"name": "Stage 1 DMG", "values": ["29.60%", "32.03%"]}) + + def test_clean_description_collapses_blank_lines(self): + self.assertEqual(clean_description("a



b"), "a\n\nb") + + def test_rotation_sheet_groups(self): + groups = parse_rotation_sheet(SHEET_CSV) + self.assertEqual([g["character"] for g in groups], ["Zani", "Cartethyia"]) + zani = groups[0] + self.assertEqual(zani["thoughts"], "Start with the 3NF quickswap rotation.") + self.assertEqual(len(zani["teams"]), 2) + top = zani["teams"][0] + self.assertEqual(top["members"], ["Zani", "Phoebe", "Rover"]) + self.assertEqual(top["style"], "Advanced Quickswap") + self.assertEqual(top["author"], "Cyan") + self.assertAlmostEqual(top["dps"], 88269.82) + self.assertTrue(top["transcript"].startswith("https://docs.google.com/document/")) + self.assertEqual(zani["teams"][1]["author"], "") # "—" means no author + carte = groups[1] + self.assertEqual(carte["thoughts"], "For casual play, learn the optimized 123 rotation.") + self.assertEqual(carte["teams"][0]["members"], ["Cartethyia Solo"]) + + def test_transcript(self): + t = parse_transcript(TRANSCRIPT) + self.assertEqual(t["glossary"]["nf"], "nightfall") + self.assertEqual(t["title"], "Zani Phoebe Rover ADV QS Cyan 4NF Transcript") + self.assertEqual(len(t["sections"]), 1) + sec = t["sections"][0] + self.assertEqual(sec["name"], "Opener") + self.assertEqual([s["character"] for s in sec["steps"]], ["phoebe", "rover", "zani", "phoebe"]) + self.assertEqual(sec["steps"][1]["moves"], ["ba23", "ha23", "echo", "lib", "fskill", "swap"]) + self.assertEqual(sec["steps"][3]["moves"], ["outro"]) + + def test_transcript_export_url_keeps_tab(self): + url = transcript_export_url("https://docs.google.com/document/d/ABC_1/edit?tab=t.xyz") + self.assertEqual(url, "https://docs.google.com/document/d/ABC_1/export?format=txt&tab=t.xyz") + self.assertEqual(transcript_export_url("https://example.com/x"), "") + + +class LibraryCacheTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.calls = [] + self.fail = False + + def fetch(url): + self.calls.append(url) + if self.fail: + raise LibraryError("offline") + return json.dumps(KIT) + + self.lib = Library(Path(self.tmp.name), fetch=fetch) + + def tearDown(self): + self.tmp.cleanup() + + def test_caches_and_serves_stale_when_offline(self): + self.assertEqual(self.lib.kit(1507)["name"], "Zani") + self.lib.kit(1507) + self.assertEqual(len(self.calls), 1) # second read came from cache + self.fail = True + stale = self.lib.kit(1507, refresh=True) + self.assertTrue(stale["stale"]) + self.assertEqual(stale["name"], "Zani") + + def test_api_routes(self): + status, body = handle_api(self.lib, "/api/ww/kit/1507") + self.assertEqual((status, body["name"]), (200, "Zani")) + self.assertEqual(handle_api(self.lib, "/api/ww/nope")[0], 404) + self.fail = True + status, body = handle_api(self.lib, "/api/ww/kit/1508") + self.assertEqual(status, 502) + self.assertIn("offline", body["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ww_moves.test.js b/tests/ww_moves.test.js new file mode 100644 index 0000000..508514d --- /dev/null +++ b/tests/ww_moves.test.js @@ -0,0 +1,60 @@ +// Run with: node --test tests/ww_moves.test.js +const test = require('node:test'); +const assert = require('node:assert'); +const { createWwMoveLabeler } = require('../static/ww_moves.js'); + +const names = { '1': 'Zani', '2': 'Phoebe', '3': 'Rover' }; +const press = (input) => ({ type: 'press', input }); + +test('leading f starts the fight, later f is Tune Break', () => { + const label = createWwMoveLabeler(names); + assert.strictEqual(label(press('f'), '1'), 'Start fight'); + assert.strictEqual(label(press('e'), '1'), 'Zani Skill'); + assert.strictEqual(label(press('f'), '1'), 'Tune Break'); +}); + +test('basic attacks count up and reset on other actions and long waits', () => { + const label = createWwMoveLabeler(names); + assert.strictEqual(label({ type: 'press_wait', input: 'lmb' }, '1'), 'Zani Basic 1'); + assert.strictEqual(label({ type: 'wait', mode: 'soft', duration: 200 }, '1'), ''); + assert.strictEqual(label(press('lmb'), '1'), 'Zani Basic 2'); + assert.strictEqual(label({ type: 'press_wait', input: 'lmb', chain_count: 2 }, '1'), 'Zani Basic 3-4'); + assert.strictEqual(label(press('rmb'), '1'), 'Dodge'); + assert.strictEqual(label(press('lmb'), '1'), 'Zani Dodge Counter?'); + assert.strictEqual(label({ type: 'wait', mode: 'soft', duration: 1200 }, '1'), ''); + assert.strictEqual(label(press('lmb'), '1'), 'Zani Basic 1'); +}); + +test('basic chain wraps at the character\'s chain length', () => { + const label = createWwMoveLabeler(names); // Zani: 4 hits + assert.strictEqual(label({ type: 'press_wait', input: 'lmb', chain_count: 5 }, '1'), 'Zani Basic 1-1'); + assert.strictEqual(label(press('lmb'), '1'), 'Zani Basic 2'); +}); + +test('hold lmb continues the chain for Hiyuki, is a Heavy for others', () => { + const label = createWwMoveLabeler({ '1': 'Hiyuki', '2': 'Augusta' }); + assert.strictEqual(label({ type: 'hold', input: 'lmb', duration: 500 }, '1'), 'Hiyuki Basic 1 (held)'); + assert.strictEqual(label({ type: 'hold', input: 'lmb', duration: 500 }, '1'), 'Hiyuki Basic 2 (held)'); + assert.strictEqual(label(press('2'), '2'), 'Augusta Intro'); + assert.strictEqual(label({ type: 'hold', input: 'lmb', duration: 500 }, '2'), 'Augusta Steelclash'); +}); + +test('lmb after a jump is a mid-air attack', () => { + const label = createWwMoveLabeler(names); + label(press('space'), '1'); + assert.strictEqual(label(press('lmb'), '1'), 'Zani Mid-air Attack'); +}); + +test('swaps name the incoming character and later moves use them', () => { + const label = createWwMoveLabeler(names); + assert.strictEqual(label(press('2'), '2'), 'Phoebe Intro'); + assert.strictEqual(label({ type: 'hold', input: 'e', duration: 800 }, '2'), 'Phoebe Held Skill'); + assert.strictEqual(label({ type: 'wait', mode: 'mandatory', wait_for: 'r', duration: 3950 }, '2'), 'Phoebe Liberation'); + assert.strictEqual(label({ type: 'hold', input: 'lmb', duration: 2300 }, '2'), 'Phoebe Heavy'); + assert.strictEqual(label(press('q'), '2'), 'Phoebe Echo'); +}); + +test('missing team falls back to slot numbers', () => { + const label = createWwMoveLabeler({}); + assert.strictEqual(label(press('3'), '3'), 'Slot 3 Intro'); +}); diff --git a/tests/ww_rotations.test.js b/tests/ww_rotations.test.js new file mode 100644 index 0000000..5bd371c --- /dev/null +++ b/tests/ww_rotations.test.js @@ -0,0 +1,38 @@ +// Run with: node --test tests/ww_rotations.test.js +const test = require('node:test'); +const assert = require('node:assert'); +const { rotationToInputs, wwMoveKeys } = require('../static/ww_rotations.js'); + +const steps = (lines) => ({ + sections: [{ name: 'Opener', steps: lines.map(([character, moves]) => ({ character, moves: moves.split(' > ') })) }], +}); + +test('moves map to keys, stage digits repeat attacks', () => { + assert.deepStrictEqual(wwMoveKeys('ba123'), ['lmb', 'lmb', 'lmb']); + assert.deepStrictEqual(wwMoveKeys('uba12'), ['lmb', 'lmb']); + assert.deepStrictEqual(wwMoveKeys('fha'), ['hold(lmb, 0.5)']); + assert.deepStrictEqual(wwMoveKeys('skill2'), ['e']); + assert.deepStrictEqual(wwMoveKeys('eskill'), ['e']); + assert.deepStrictEqual(wwMoveKeys('lib2'), ['r']); + assert.deepStrictEqual(wwMoveKeys('tbs'), ['f']); + assert.deepStrictEqual(wwMoveKeys('dash'), ['rmb']); + assert.strictEqual(wwMoveKeys('nf'), null); +}); + +test('swaps use the team slot order and the opener swaps in the first character', () => { + const t = steps([ + ['phoebe', 'eskill > lib > swap'], + ['zani', 'skill > ba3 > nf > swap'], + ['rover', 'echo > outro'], + ['phoebe', 'outro'], + ]); + const out = rotationToInputs(t, ['Zani', 'Phoebe', 'Rover']); + assert.strictEqual(out.inputs, '2, e, r, 1, e, lmb, 3, q, 2'); + assert.deepStrictEqual(out.unmapped, ['nf']); +}); + +test('unknown characters are reported instead of guessed', () => { + const out = rotationToInputs(steps([['zani', 'skill > swap'], ['verina', 'lib']]), ['Zani', 'Phoebe', 'Rover']); + assert.strictEqual(out.inputs, 'e, r'); + assert.deepStrictEqual(out.unmapped, ['swap to verina']); +}); diff --git a/tools/ww_character_sync.py b/tools/ww_character_sync.py index 0e62494..56958b3 100644 --- a/tools/ww_character_sync.py +++ b/tools/ww_character_sync.py @@ -45,74 +45,18 @@ import argparse import asyncio -import html as html_mod import json -import re import sys -import urllib.error -import urllib.request from pathlib import Path from typing import Any from uuid import uuid4 REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) DEFAULT_COMBOS_PATH = REPO_ROOT / "combos.json" DEFAULT_WS_URI = "ws://localhost:8765" -USER_AGENT = "Mozilla/5.0 (compatible; ComboTracker-tools)" -IMG_HOST = "https://wuthering.gg" - -# The site emits these as HTML-escaped relative paths (e.g. "/_ipx/q_70&s_100x100/..."). -# We unescape entities and prefix the host before matching to get real, usable URLs. -ICON_PATTERNS = { - "swap_image": r"/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_\d+(?:_UI)?\.png", - "lmb_image": r"/_ipx/q_70&s_32x32/images/iconskill/SP_IconNor[A-Za-z0-9]*\.png", - "e": r"/_ipx/q_70&s_32x32/images/iconskill/[A-Za-z0-9_]+B1\.png", - "r": r"/_ipx/q_70&s_32x32/images/iconskill/[A-Za-z0-9_]+C1\.png", - "q": r"/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_\d+_UI\.png", -} - - -def _dedupe_preserve_order(items: list[str]) -> list[str]: - seen: set[str] = set() - out: list[str] = [] - for it in items: - if it not in seen: - seen.add(it) - out.append(it) - return out - - -def fetch_character_icons(slug: str) -> dict[str, Any]: - """Scrape https://wuthering.gg/characters/ for the standard icon set.""" - url = f"https://wuthering.gg/characters/{slug.strip().lower()}" - req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - raw_html = resp.read().decode("utf-8", errors="replace") - except urllib.error.HTTPError as e: - raise RuntimeError(f"{url} -> HTTP {e.code}. Check the character slug.") from e - except urllib.error.URLError as e: - raise RuntimeError(f"Could not reach {url}: {e.reason}") from e - - html = html_mod.unescape(raw_html) - - result: dict[str, Any] = {"swap_image": "", "lmb_image": "", "ability_images": {"q": "", "e": "", "r": ""}} - missing = [] - for field, pattern in ICON_PATTERNS.items(): - matches = _dedupe_preserve_order(re.findall(pattern, html)) - value = (IMG_HOST + matches[0]) if matches else "" - if not value: - missing.append(field) - if field in ("q", "e", "r"): - result["ability_images"][field] = value - else: - result[field] = value - - if missing: - print(f" warning: could not find {', '.join(missing)} on {url} -- fill in manually.", file=sys.stderr) - - return result +from ww_icons import fetch_character_icons # noqa: E402 (needs REPO_ROOT on sys.path) # --------------------------------------------------------------------------- @@ -224,14 +168,22 @@ def delete_team_direct(path: Path, team_id: str) -> None: # CLI # --------------------------------------------------------------------------- +def _warn_missing(icons: dict[str, Any], slug: str) -> None: + if icons.get("missing"): + print(f" warning: could not find {', '.join(icons['missing'])} for {slug} -- fill in manually.", file=sys.stderr) + + def cmd_fetch(args: argparse.Namespace) -> None: icons = fetch_character_icons(args.slug) + _warn_missing(icons, args.slug) print(json.dumps({"name": args.name or args.slug.capitalize(), **icons}, indent=2)) def cmd_add_character(args: argparse.Namespace) -> None: name = args.name or args.slug.capitalize() icons = fetch_character_icons(args.slug) + _warn_missing(icons, args.slug) + icons.pop("missing", None) if args.swap: icons["swap_image"] = args.swap if args.lmb: diff --git a/ui_server.py b/ui_server.py index 72015bc..448f2fc 100644 --- a/ui_server.py +++ b/ui_server.py @@ -12,13 +12,15 @@ from socketserver import TCPServer, ThreadingMixIn from typing import Any, Callable -import websockets +from websockets.asyncio.server import ServerConnection, serve from pynput import keyboard, mouse from combo_engine import ComboTrackerEngine from macro_player import MacroPlayer from profiling.transcription_log import TranscriptionLogWriter from transcriber import Transcriber +import ww_sync +import ww_library logger = logging.getLogger(__name__) @@ -63,18 +65,52 @@ def _normalized_macro_stop_key() -> str: transcribe_mode_enabled = False macro_mode_enabled = False +# Keys shown on the live key overlay (static/keys.html, a built-in NohBoard-style display). +# Only these are broadcast, so typing elsewhere never leaves the listener. +OVERLAY_KEYS = frozenset( + {"w", "a", "s", "d", "shift", "space", "1", "2", "3", "q", "e", "r", "f", "lmb", "rmb"} +) +_overlay_down: set[str] = set() + + +def _emit_overlay_input(input_name: str, down: bool) -> None: + """Tell overlay clients a key went down or up (key-repeat presses are dropped).""" + if input_name not in OVERLAY_KEYS: + return + if down: + if input_name in _overlay_down: + return + _overlay_down.add(input_name) + else: + _overlay_down.discard(input_name) + engine._send({"type": "key_input", "key": input_name, "down": down}) + HOST_HTTP = "localhost" -PORT_HTTP = 8080 +PORT_HTTP = 8737 HOST_WS = "localhost" PORT_WS = 8765 +# Only the bundled UI (and non-browser clients, which send no Origin header) may +# connect. Without this, any website open in the browser could connect to the +# local WebSocket and edit or delete combos. +ALLOWED_WS_ORIGINS = [ + f"http://localhost:{PORT_HTTP}", + f"http://127.0.0.1:{PORT_HTTP}", + None, +] + class ThreadedHTTPServer(ThreadingMixIn, TCPServer): """Handle each HTTP request in its own thread so multiple tabs can reload without blocking.""" + daemon_threads = True + # Lets a quick restart rebind while old sockets sit in TIME_WAIT. Not on Windows, + # where SO_REUSEADDR would let a second process steal a port already in use. + allow_reuse_address = sys.platform != "win32" + -def serve_static() -> None: +def make_static_server() -> ThreadedHTTPServer: # When packaged with PyInstaller, static files are in sys._MEIPASS if getattr(sys, "frozen", False): base = Path(sys._MEIPASS) @@ -82,16 +118,49 @@ def serve_static() -> None: base = Path(__file__).resolve().parent static_dir = (base / "static").resolve() + library = ww_library.Library(engine.data_dir / "ww_library_cache") + class Handler(SimpleHTTPRequestHandler): + # mimetypes reads the Windows registry, which other apps can corrupt + # (e.g. .html -> video/html makes the browser download the page). + extensions_map = { + **SimpleHTTPRequestHandler.extensions_map, + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".webp": "image/webp", + ".ico": "image/x-icon", + } + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, directory=str(static_dir), **kwargs) - with ThreadedHTTPServer((HOST_HTTP, PORT_HTTP), Handler) as httpd: + def do_GET(self) -> None: + # Character library (static/characters.html) is served as JSON; everything else is a static file. + if not self.path.startswith(ww_library.API_PREFIX): + return super().do_GET() + status, payload = ww_library.handle_api(library, self.path) + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + return ThreadedHTTPServer((HOST_HTTP, PORT_HTTP), Handler) + + +def serve_static(httpd: ThreadedHTTPServer) -> None: + with httpd: print(f"HTTP server running at http://{HOST_HTTP}:{PORT_HTTP}") httpd.serve_forever() -connected_clients: set[websockets.WebSocketServerProtocol] = set() +connected_clients: set[ServerConnection] = set() async def broadcast_dict(payload: dict[str, Any]) -> None: @@ -128,10 +197,7 @@ def _safe_json_load(s: str) -> dict[str, Any] | None: return None -async def ws_handler( - websocket: websockets.WebSocketServerProtocol, - _path: str | None = None, -) -> None: +async def ws_handler(websocket: ServerConnection) -> None: global transcribe_mode_enabled, macro_mode_enabled connected_clients.add(websocket) print(f"Client connected. Total: {len(connected_clients)}") @@ -152,14 +218,14 @@ async def ws_handler( ok, err = engine.save_or_update_combo( name=str(msg.get("name") or ""), inputs=str(msg.get("inputs") or ""), - enders=str(msg.get("enders") or ""), + enders=None if msg.get("enders") is None else str(msg.get("enders")), expected_time=str(msg.get("expected_time") or ""), user_difficulty=str(msg.get("user_difficulty") or ""), step_display_mode=str(msg.get("step_display_mode") or ""), key_images=msg.get("key_images"), demo_video=str(msg.get("demo_video") or ""), - target_game=str(msg.get("target_game") or ""), ww_team_id=str(msg.get("ww_team_id") or ""), + as_new=bool(msg.get("as_new")), ) if not ok and err: await websocket.send(json.dumps({"type": "status", "text": err, "color": "fail"})) @@ -182,6 +248,10 @@ async def ws_handler( ) if not ok and err: await websocket.send(json.dumps({"type": "status", "text": err, "color": "fail"})) + elif mtype == "sync_character": + await ww_sync.sync_character(engine, str(msg.get("name") or "")) + elif mtype == "sync_all_characters": + await ww_sync.refresh_all_characters(engine) elif mtype == "delete_character": ok, err = engine.delete_ww_character(str(msg.get("name") or "")) if not ok and err: @@ -189,16 +259,8 @@ async def ws_handler( elif mtype == "update_ww_dash": engine.update_ww_dash(str(msg.get("dash_image") or "")) elif mtype == "select_team": - # Stateless team selection: accepts target_game parameter - target_game = str(msg.get("target_game") or "").strip().lower() - engine.select_team_stateless( - team_id=str(msg.get("team_id") or ""), - target_game=target_game - ) - elif mtype == "update_target_game": - # Stateless target game update (doesn't persist to JSON) - target_game = str(msg.get("target_game") or "").strip().lower() - engine.update_target_game_stateless(target_game) + # Stateless team selection (doesn't persist to JSON) + engine.select_team_stateless(team_id=str(msg.get("team_id") or "")) elif mtype == "delete_team": ok, err = engine.delete_ww_team(str(msg.get("team_id") or "")) if not ok and err: @@ -356,7 +418,7 @@ def run_ws_server() -> None: engine.set_emitter(make_threadsafe_emitter(loop)) async def _main(): - async with websockets.serve(ws_handler, HOST_WS, PORT_WS): + async with serve(ws_handler, HOST_WS, PORT_WS, origins=ALLOWED_WS_ORIGINS): print(f"WebSocket server running at ws://{HOST_WS}:{PORT_WS}") await asyncio.Future() # run forever @@ -367,6 +429,7 @@ def start_input_listeners() -> tuple[keyboard.Listener, mouse.Listener]: def on_key_press(key: keyboard.Key | keyboard.KeyCode | None) -> None: event_time = time.perf_counter() input_name = engine.normalize_key(key) + _emit_overlay_input(input_name, True) # macro playback shows on the overlay too # Macro output is matched by origin before hotkey handling. This prevents # a synthetic Esc/F9 from stopping its own macro while physical stop keys @@ -420,6 +483,7 @@ def on_key_press(key: keyboard.Key | keyboard.KeyCode | None) -> None: def on_key_release(key: keyboard.Key | keyboard.KeyCode | None) -> None: event_time = time.perf_counter() input_name = engine.normalize_key(key) + _emit_overlay_input(input_name, False) if macro_player.consume_synthetic_event(input_name, False): return if transcribe_mode_enabled: @@ -436,6 +500,7 @@ def on_key_release(key: keyboard.Key | keyboard.KeyCode | None) -> None: def on_mouse_click(_x: float, _y: float, button: mouse.Button, pressed: bool) -> None: event_time = time.perf_counter() btn = engine.normalize_mouse(button) + _emit_overlay_input(btn, pressed) if macro_player.consume_synthetic_event(btn, pressed): return if transcribe_mode_enabled: @@ -555,8 +620,20 @@ def setup_logging() -> None: def main() -> None: _warn_if_windows_non_elevated() setup_logging() - # Static UI - http_thread = threading.Thread(target=serve_static, daemon=True) + # Static UI. Bind here so a busy port stops startup with a clear message + # instead of silently killing the HTTP thread. + try: + httpd = make_static_server() + except OSError as e: + print( + f"Error: could not open http://{HOST_HTTP}:{PORT_HTTP} ({e}). " + "Is ComboTracker already running? Close the other copy and try again.", + file=sys.stderr, + ) + if getattr(sys, "frozen", False): + input("Press Enter to close...") # keep the packaged console window readable + sys.exit(1) + http_thread = threading.Thread(target=serve_static, args=(httpd,), daemon=True) http_thread.start() # WebSocket server (owns its asyncio loop) diff --git a/ww_icons.py b/ww_icons.py new file mode 100644 index 0000000..dcb8b2f --- /dev/null +++ b/ww_icons.py @@ -0,0 +1,82 @@ +""" +Wuthering Waves character icons scraped from wuthering.gg. + +Used by the app's "Add from wuthering.gg" / "Refresh all icons" buttons and by +tools/ww_character_sync.py. + +Icon field convention (matches existing entries in combos.json) + swap_image -> 100x100 head icon (iconrolehead150) + lmb_image -> normal attack icon (iconskill/SP_IconNor*.png) + e -> resonance skill icon (iconskill/*B1.png) + r -> resonance liberation icon (iconskill/*C1.png) + q -> the recommended 4-cost main Echo icon from the build page + (mstskill/T_MstSkil__UI.png); characters sharing an Echo + legitimately share this icon. +""" + +from __future__ import annotations + +import html as html_mod +import re +import urllib.error +import urllib.request +from typing import Any + +USER_AGENT = "Mozilla/5.0 (compatible; ComboTracker-tools)" +IMG_HOST = "https://wuthering.gg" + +# The site emits these as HTML-escaped relative paths (e.g. "/_ipx/q_70&s_100x100/..."). +# We unescape entities and prefix the host before matching to get real, usable URLs. +ICON_PATTERNS = { + "swap_image": r"/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_\d+(?:_UI)?\.png", + "lmb_image": r"/_ipx/q_70&s_32x32/images/iconskill/SP_IconNor[A-Za-z0-9]*\.png", + "e": r"/_ipx/q_70&s_32x32/images/iconskill/[A-Za-z0-9_]+B1\.png", + "r": r"/_ipx/q_70&s_32x32/images/iconskill/[A-Za-z0-9_]+C1\.png", + "q": r"/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_\d+_UI\.png", +} + + +def character_slug(name: str) -> str: + """wuthering.gg page slug for a character name: 'Phrolova' -> 'phrolova', 'Jiyan Zhe' -> 'jiyan-zhe'.""" + return re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-") + + +def parse_character_icons(raw_html: str) -> dict[str, Any]: + """ + Pull the standard icon set out of a wuthering.gg character page. + Returns {"swap_image", "lmb_image", "ability_images": {q,e,r}, "missing": [field, ...]}. + """ + page = html_mod.unescape(raw_html) + result: dict[str, Any] = {"swap_image": "", "lmb_image": "", "ability_images": {"q": "", "e": "", "r": ""}} + missing: list[str] = [] + for field, pattern in ICON_PATTERNS.items(): + m = re.search(pattern, page) + value = (IMG_HOST + m.group(0)) if m else "" + if not value: + missing.append(field) + if field in ("q", "e", "r"): + result["ability_images"][field] = value + else: + result[field] = value + result["missing"] = missing + return result + + +def fetch_character_icons(slug: str) -> dict[str, Any]: + """Scrape https://wuthering.gg/characters/. Raises RuntimeError when the page can't be read.""" + url = f"{IMG_HOST}/characters/{character_slug(slug)}" + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw_html = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + if e.code == 404: + raise RuntimeError(f"wuthering.gg has no character page named '{slug}'. Check the spelling.") from e + raise RuntimeError(f"{url} -> HTTP {e.code}.") from e + except urllib.error.URLError as e: + raise RuntimeError(f"Could not reach wuthering.gg: {e.reason}") from e + + result = parse_character_icons(raw_html) + if not result["swap_image"] and len(result["missing"]) == len(ICON_PATTERNS): + raise RuntimeError(f"No icons found on {url}. The site layout may have changed.") + return result diff --git a/ww_library.py b/ww_library.py new file mode 100644 index 0000000..fef225f --- /dev/null +++ b/ww_library.py @@ -0,0 +1,396 @@ +""" +Wuthering Waves character library: every character's moves and the community's team rotations. + +Sources (fetched by the app on demand, then cached on disk so the page works offline): + - encore.moe JSON API: character list and each character's full kit (skill text, multipliers, icons) + https://api.encore.moe/en/character -> {"roleList": [{Id, Name, Element, WeaponType, RoleHeadIcon}]} + https://api.encore.moe/en/character/ -> {..., "Skills": [{SkillType, SkillName, SkillDescribe, Icon, + SkillAttributes: [{attributeName, values: [lv1..lv10]}]}]} + - AntoCrasher's calc compilation (Google Sheet): one block per main DPS listing team setups with author, + DPS, video, and a link to a Google Doc tab holding the move-by-move rotation transcript. + +Served to static/characters.html by ui_server.py under /api/ww/... +""" + +from __future__ import annotations + +import csv +import html as html_mod +import io +import json +import re +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +USER_AGENT = "Mozilla/5.0 (compatible; ComboTracker)" +ENCORE_API = "https://api.encore.moe/en/character" +ROTATION_SHEET_ID = "1mdl9J08N-0_j-U2zNP5OTHGBKprwmJEmz_Iy4IOJfPk" +ROTATION_SHEET_TAB = "2 Minute Calc Compilation" +ROTATION_SHEET_URL = f"https://docs.google.com/spreadsheets/d/{ROTATION_SHEET_ID}/edit" + +# How long a cached copy counts as fresh; stale copies are still used when the network is down. +CACHE_TTL_S = 7 * 24 * 3600 + +# encore.moe SkillType -> the key Wolf presses for it (shown as a badge on the Moves tab). +SKILL_KEYS = { + "Normal Attack": "LMB", + "Resonance Skill": "E", + "Resonance Liberation": "R", + "Intro Skill": "Swap in", + "Outro Skill": "Swap out", + "Tune Break": "F", +} + +# Order the Moves tab lists skill types in; unknown types go last in API order. +SKILL_TYPE_ORDER = [ + "Normal Attack", + "Resonance Skill", + "Forte Circuit", + "Resonance Liberation", + "Intro Skill", + "Outro Skill", + "Tune Break", + "Inherent Skill", +] + + +class LibraryError(RuntimeError): + """A source couldn't be read and there is no cached copy to fall back on.""" + + +# --------------------------------------------------------------------------- +# Fetch + cache +# --------------------------------------------------------------------------- + +def _http_get(url: str, timeout: float = 20) -> str: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + raise LibraryError(f"{urllib.parse.urlsplit(url).netloc} answered HTTP {e.code}.") from e + except urllib.error.URLError as e: + raise LibraryError(f"Could not reach {urllib.parse.urlsplit(url).netloc}: {e.reason}") from e + except TimeoutError as e: + raise LibraryError(f"{urllib.parse.urlsplit(url).netloc} timed out.") from e + + +class Library: + """Fetches and normalizes the sources, caching each result as JSON under cache_dir.""" + + def __init__(self, cache_dir: Path, fetch=_http_get) -> None: + self.cache_dir = Path(cache_dir) + self._fetch = fetch + self._lock = threading.Lock() + + def _cached(self, name: str, build, refresh: bool) -> dict[str, Any]: + path = self.cache_dir / f"{name}.json" + cached: dict[str, Any] | None = None + if path.exists(): + try: + cached = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + cached = None + if cached and not refresh and time.time() - cached.get("fetched_at", 0) < CACHE_TTL_S: + return cached + try: + data = build() + except LibraryError as e: + if cached: + return dict(cached, stale=True, error=str(e)) + raise + data["fetched_at"] = time.time() + with self._lock: + self.cache_dir.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + return data + + def roster(self, refresh: bool = False) -> dict[str, Any]: + return self._cached("roster", lambda: parse_roster(json.loads(self._fetch(ENCORE_API))), refresh) + + def kit(self, char_id: int, refresh: bool = False) -> dict[str, Any]: + char_id = int(char_id) + return self._cached( + f"kit_{char_id}", lambda: parse_kit(json.loads(self._fetch(f"{ENCORE_API}/{char_id}"))), refresh + ) + + def rotations(self, refresh: bool = False) -> dict[str, Any]: + def build() -> dict[str, Any]: + url = ( + f"https://docs.google.com/spreadsheets/d/{ROTATION_SHEET_ID}/gviz/tq?tqx=out:csv&sheet=" + + urllib.parse.quote(ROTATION_SHEET_TAB) + ) + groups = parse_rotation_sheet(self._fetch(url)) + if not groups: + raise LibraryError("The rotation sheet had no team rows. Its layout may have changed.") + return {"source": ROTATION_SHEET_URL, "groups": groups} + + return self._cached("rotations", build, refresh) + + def transcript(self, doc_url: str, refresh: bool = False) -> dict[str, Any]: + export = transcript_export_url(doc_url) + if not export: + raise LibraryError("That isn't a Google Docs link.") + key = re.sub(r"[^A-Za-z0-9]+", "_", export.split("/d/", 1)[1])[:120] + return self._cached( + f"transcript_{key}", lambda: dict(parse_transcript(self._fetch(export)), source=doc_url), refresh + ) + + +# --------------------------------------------------------------------------- +# encore.moe +# --------------------------------------------------------------------------- + +def _name_of(v: Any) -> str: + return (v.get("Name") if isinstance(v, dict) else v) or "" + + +def parse_roster(data: dict[str, Any]) -> dict[str, Any]: + """Character list. Rover appears once per gender; keep the first of each name.""" + seen: set[str] = set() + chars = [] + for r in data.get("roleList") or []: + name = (r.get("Name") or "").strip() + if not name or name in seen: + continue + seen.add(name) + chars.append({ + "id": r.get("Id"), + "name": name, + "rarity": r.get("QualityId"), + "element": _name_of(r.get("Element")), + "weapon": _name_of(r.get("WeaponType")), + "icon": r.get("RoleHeadIcon") or "", + }) + chars.sort(key=lambda c: c["name"].lower()) + return {"source": ENCORE_API, "characters": chars} + + +_BR = re.compile(r"<\s*br\s*/?\s*>", re.I) +_TAG = re.compile(r"<[^>]+>") +_BOLD = re.compile(r']*font-bold[^>]*>(.*?)', re.I | re.S) + + +def clean_description(raw: str) -> str: + """encore.moe skill HTML -> plain text. Bold spans (move names) become **text**;
becomes a newline.""" + s = _BR.sub("\n", raw or "") + s = _BOLD.sub(lambda m: "**" + _TAG.sub("", m.group(1)).strip() + "**", s) + s = html_mod.unescape(_TAG.sub("", s)) + s = re.sub(r"[ \t]+", " ", s) + s = re.sub(r"\n{3,}", "\n\n", s) + return s.strip() + + +def parse_kit(data: dict[str, Any]) -> dict[str, Any]: + skills = [] + for sk in data.get("Skills") or []: + stype = (sk.get("SkillType") or "").strip() + skills.append({ + "id": sk.get("SkillId"), + "type": stype, + "key": SKILL_KEYS.get(stype, ""), + "name": (sk.get("SkillName") or "").strip(), + "icon": sk.get("Icon") or "", + "video": sk.get("SkillMedia") or "", + "description": clean_description(sk.get("SkillDescribe") or ""), + "multipliers": [ + {"name": (a.get("attributeName") or "").strip(), "values": [str(v) for v in a.get("values") or []]} + for a in sk.get("SkillAttributes") or [] + if (a.get("attributeName") or "").strip() + ], + }) + order = {t: i for i, t in enumerate(SKILL_TYPE_ORDER)} + skills.sort(key=lambda s: order.get(s["type"], len(order))) # stable: keeps API order within a type + name = data.get("Name") + return { + "id": data.get("Id"), + "name": (name.get("Content") if isinstance(name, dict) else name) or "", + "element": data.get("ElementName") or _name_of(data.get("Element")), + "icon": data.get("RoleHeadIcon") or data.get("RoleHeadIconCircle") or "", + "skills": skills, + "source": f"{ENCORE_API}/{data.get('Id')}", + } + + +# --------------------------------------------------------------------------- +# AntoCrasher rotation sheet +# --------------------------------------------------------------------------- + +_BLOCK_TITLE = re.compile(r"^\s*AntoCrasher Calc\s*-\s*(.+?)\s+Team\b", re.I) +_TEAM_TAGS = re.compile(r"\s*\(([^)]*)\)\s*$") + + +def _thoughts(cells: list[str]) -> str: + """'Personal Thoughts' text: either inside the same cell or in the next non-empty cell.""" + for i, c in enumerate(cells): + if c.strip().lower().startswith("personal thoughts"): + rest = c.strip()[len("personal thoughts"):].strip().strip('"').strip() + if rest: + return rest + for nxt in cells[i + 1:]: + if nxt.strip(): + return nxt.strip() + return "" + + +def _team_members(team: str) -> list[str]: + """'Zani, Phoebe S0R1, Rover S6R1 (Advanced Quickswap)' -> ['Zani', 'Phoebe', 'Rover'].""" + base = _TEAM_TAGS.sub("", team) + out = [] + for part in base.split(","): + words = [w for w in part.split() if not re.fullmatch(r"S\d+R\d+", w, re.I)] + if words: + out.append(" ".join(words)) + return out + + +def _num(s: str) -> float | None: + try: + return float(s.replace(",", "").replace("%", "").strip()) + except (ValueError, AttributeError): + return None + + +def parse_rotation_sheet(csv_text: str) -> list[dict[str, Any]]: + """Split the compilation into one group per main character with its team rows.""" + groups: list[dict[str, Any]] = [] + cols: dict[str, int] = {} + group: dict[str, Any] | None = None + for cells in csv.reader(io.StringIO(csv_text)): + cells = [c.strip() for c in cells] + joined = " ".join(cells) + title = next((m for m in (_BLOCK_TITLE.match(c) for c in cells) if m), None) + if title: + group = {"character": title.group(1).strip(), "title": title.string.strip(), "thoughts": "", "teams": []} + groups.append(group) + if group is not None and not group["thoughts"]: + group["thoughts"] = _thoughts(cells) + if "Author" in cells: + # Later blocks repeat a partial header (blank damage columns); keep earlier positions for those. + cols = {**cols, **{c.lower(): i for i, c in enumerate(cells) if c}} + continue + if title or group is None or not cols or not joined.strip(): + continue + + def col(name: str) -> str: + i = cols.get(name) + return cells[i] if i is not None and i < len(cells) else "" + + team = next((c for c in cells[: cols.get("author", 1)] if c), "") + dps = _num(col("dps")) + if not team or dps is None: + continue + tags = _TEAM_TAGS.search(team) + author = col("author") + group["teams"].append({ + "team": team, + "members": _team_members(team), + "style": tags.group(1).strip() if tags else "", + "author": "" if author in ("—", "-") else author, + "setup": col("extra info"), + "dps": dps, + "total_damage": _num(col("total damage")), + "rotation_time": _num(col("rotation time")), + "relative": col("%"), + "video": col("video showcase"), + "transcript": col("rotation transcript"), + "calc_sheet": col("calc sheet"), + }) + return [g for g in groups if g["teams"]] + + +# --------------------------------------------------------------------------- +# Rotation transcripts (Google Docs) +# --------------------------------------------------------------------------- + +def transcript_export_url(doc_url: str) -> str: + """Docs link (optionally ?tab=t.xxx) -> its plain-text export for that tab.""" + m = re.match(r"https://docs\.google\.com/document/d/([A-Za-z0-9_-]+)", doc_url or "") + if not m: + return "" + tab = urllib.parse.parse_qs(urllib.parse.urlsplit(doc_url).query).get("tab", [""])[0] + url = f"https://docs.google.com/document/d/{m.group(1)}/export?format=txt" + return url + (f"&tab={urllib.parse.quote(tab)}" if tab else "") + + +_GLOSSARY = re.compile(r"^([a-z0-9]+)\s*=\s*(.+)$", re.I) +_ACTION_LINE = re.compile(r"^([A-Za-z][\w .'-]{0,30}):\s*(.+)$") + + +def parse_transcript(text: str) -> dict[str, Any]: + """ + Transcript text -> glossary + sections of ": move > move > swap" lines. + + The hub doc starts with an abbreviation list ("ba = basic attack"), then a title line + ("Zani Phoebe Rover ADV QS Cyan 4NF Transcript") and sections ("Opener", "Rotation 1"...). + """ + glossary: dict[str, str] = {} + title = "" + sections: list[dict[str, Any]] = [] + notes: list[str] = [] + for raw in (text or "").replace("", "").splitlines(): + line = raw.strip() + if not line: + continue + g = _GLOSSARY.match(line) + if g and not any(sec["steps"] for sec in sections): + glossary[g.group(1).lower()] = g.group(2).strip() + continue + a = _ACTION_LINE.match(line) + if a and (">" in a.group(2) or (sections and len(a.group(2).split()) <= 3)): + if not sections: + sections.append({"name": "", "steps": []}) + moves = [m.strip() for m in a.group(2).split(">") if m.strip()] + sections[-1]["steps"].append({"character": a.group(1).strip(), "moves": moves}) + continue + if line.lower().endswith("transcript") and not title: + title = line + continue + if len(line) <= 40 and not line.endswith("."): + sections.append({"name": line, "steps": []}) + continue + notes.append(line) + sections = [s for s in sections if s["steps"]] + return {"title": title, "glossary": glossary, "sections": sections, "notes": notes[:20]} + + +# --------------------------------------------------------------------------- +# HTTP API used by static/characters.html +# --------------------------------------------------------------------------- + +API_PREFIX = "/api/ww/" + + +def handle_api(library: Library, path: str) -> tuple[int, dict[str, Any]]: + """ + Route one GET under /api/ww/. Add ?refresh=1 to bypass the cache. + roster -> character list + kit/ -> one character's moves + rotations -> community team rotations grouped by main character + transcript?url= -> one rotation's move-by-move transcript + """ + parts = urllib.parse.urlsplit(path) + query = urllib.parse.parse_qs(parts.query) + refresh = query.get("refresh", [""])[0] in ("1", "true") + route = parts.path[len(API_PREFIX):].strip("/") + try: + if route == "roster": + return 200, library.roster(refresh) + if route.startswith("kit/") and route[4:].isdigit(): + return 200, library.kit(int(route[4:]), refresh) + if route == "rotations": + return 200, library.rotations(refresh) + if route == "transcript": + return 200, library.transcript(query.get("url", [""])[0], refresh) + except LibraryError as e: + return 502, {"error": str(e)} + except (ValueError, KeyError, TypeError) as e: + return 502, {"error": f"Unexpected data from the source: {e}"} + return 404, {"error": f"Unknown API route: {route}"} diff --git a/ww_sync.py b/ww_sync.py new file mode 100644 index 0000000..2b35f3f --- /dev/null +++ b/ww_sync.py @@ -0,0 +1,83 @@ +""" +One-click character sync: add or refresh Wuthering Waves characters from wuthering.gg +(the websocket "sync_character" / "sync_all_characters" messages). +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from ww_icons import fetch_character_icons + + +def _send_status(engine: Any, text: str, color: str) -> None: + # Goes through the engine's emitter (not a direct websocket send) so it lands after the + # init payload that saving a character broadcasts, instead of being overwritten by it. + engine._send({"type": "status", "text": text, "color": color}) + + +async def sync_character(engine: Any, name: str) -> None: + """Add or refresh one character from wuthering.gg, keeping any icon the site doesn't have.""" + name = " ".join((name or "").split()) + if not name: + _send_status(engine, "Type a character name first.", "fail") + return + _send_status(engine, f"Fetching {name} from wuthering.gg...", "neutral") + try: + icons = await asyncio.to_thread(fetch_character_icons, name) + except RuntimeError as e: + _send_status(engine, str(e), "fail") + return + + existing = engine.ww.ww_characters.get(name.lower()) or {} + old_abilities = existing.get("ability_images") or {} + display_name = existing.get("name") or name.title() + ok, err = engine.save_ww_character( + name=display_name, + swap_image=icons["swap_image"] or existing.get("swap_image", ""), + lmb_image=icons["lmb_image"] or existing.get("lmb_image", ""), + ability_images={k: v or old_abilities.get(k, "") for k, v in icons["ability_images"].items()}, + ) + if not ok: + _send_status(engine, err or f"Could not save {display_name}.", "fail") + elif icons["missing"]: + _send_status( + engine, + f"Saved {display_name}, but wuthering.gg had no {', '.join(icons['missing'])} icon. Add it by hand.", + "fail", + ) + else: + _send_status(engine, f"Saved {display_name} with icons from wuthering.gg.", "success") + + +async def refresh_all_characters(engine: Any) -> None: + """Re-fetch icons for every saved character (fixes broken links after site updates).""" + chars = [dict(c, key=k) for k, c in engine.ww.ww_characters.items() if isinstance(c, dict)] + if not chars: + _send_status(engine, "No characters saved yet.", "fail") + return + failed: list[str] = [] + for i, ch in enumerate(chars, 1): + name = ch.get("name") or ch["key"] + _send_status(engine, f"Refreshing icons {i}/{len(chars)}: {name}...", "neutral") + try: + icons = await asyncio.to_thread(fetch_character_icons, name) + except RuntimeError: + failed.append(name) + continue + old_abilities = ch.get("ability_images") or {} + engine.save_ww_character( + name=name, + swap_image=icons["swap_image"] or ch.get("swap_image", ""), + lmb_image=icons["lmb_image"] or ch.get("lmb_image", ""), + ability_images={k: v or old_abilities.get(k, "") for k, v in icons["ability_images"].items()}, + ) + if failed: + _send_status( + engine, + f"Refreshed {len(chars) - len(failed)} of {len(chars)}. Not found on wuthering.gg: {', '.join(failed)}.", + "fail", + ) + else: + _send_status(engine, f"Refreshed icons for all {len(chars)} characters.", "success") From e51658d554ace64b09aa73881997076f22f72e35 Mon Sep 17 00:00:00 2001 From: WXP Date: Wed, 30 Sep 2026 21:56:58 -0400 Subject: [PATCH 2/7] updated UI --- _combo_commands.py | 4 +- combo_engine.py | 12 +- combo_engine_ui.py | 27 + combos.json | 4 +- static/app.js | 3151 ---------------------------------- static/app/combos.js | 669 ++++++++ static/app/core.js | 108 ++ static/app/practice.js | 413 +++++ static/app/shell.js | 513 ++++++ static/app/teams.js | 602 +++++++ static/app/timeline.js | 1325 ++++++++++++++ static/characters.css | 55 +- static/characters.html | 1 + static/characters.js | 66 +- static/index.html | 697 +++++--- static/keys.html | 1 + static/keys.js | 26 +- static/shell.css | 886 ++++++++++ static/ws.js | 57 + tests/test_combo_overview.py | 62 + 20 files changed, 5162 insertions(+), 3517 deletions(-) delete mode 100644 static/app.js create mode 100644 static/app/combos.js create mode 100644 static/app/core.js create mode 100644 static/app/practice.js create mode 100644 static/app/shell.js create mode 100644 static/app/teams.js create mode 100644 static/app/timeline.js create mode 100644 static/shell.css create mode 100644 static/ws.js create mode 100644 tests/test_combo_overview.py diff --git a/_combo_commands.py b/_combo_commands.py index 10b3dd6..cf8dcf9 100644 --- a/_combo_commands.py +++ b/_combo_commands.py @@ -218,7 +218,7 @@ def clear_history_and_stats(engine) -> None: engine.combo_stats[engine.active_combo_name] = fresh_combo_stats() engine.save_combos() engine._send({"type": "clear_results"}) - engine._send({"type": "stat_update", "stats": engine.stats_text()}) + engine._send(engine.stat_update_payload()) engine._send({"type": "fail_update", "fail_by_step": engine.failures_by_step()}) engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) st = engine.get_status() @@ -232,7 +232,7 @@ def clear_all_history_and_stats(engine) -> None: engine.combo_stats[name] = fresh_combo_stats() engine.save_combos() engine._send({"type": "clear_results"}) - engine._send({"type": "stat_update", "stats": engine.stats_text()}) + engine._send(engine.stat_update_payload()) engine._send({"type": "fail_update", "fail_by_step": engine.failures_by_step()}) engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) st = engine.get_status() diff --git a/combo_engine.py b/combo_engine.py index e5d9b45..8f57fe8 100644 --- a/combo_engine.py +++ b/combo_engine.py @@ -226,7 +226,7 @@ def _send(self, msg: dict[str, Any]): logger.debug("Emitter raised while sending message", exc_info=True) def _emit_stats_and_fail(self): - self._send({"type": "stat_update", "stats": self.stats_text()}) + self._send(ui.stat_update_payload(self)) self._send({"type": "fail_update", "fail_by_step": self.failures_by_step()}) # ------------------------- @@ -338,6 +338,9 @@ def _format_percent(self, success: int, fail: int) -> str: def stats_text(self): return ui.stats_text(self) + def stat_update_payload(self) -> dict[str, Any]: + return ui.stat_update_payload(self) + def failures_by_step(self) -> dict[str, int]: return ui.failures_by_step(self) @@ -711,7 +714,12 @@ def set_active_combo(self, name: str, *, emit: bool = True): self._emit_stats_and_fail() self._send({"type": "timeline_update", "steps": self.timeline_steps()}) self._send({"type": "status", "text": st.text, "color": st.color}) - self._send({"type": "combo_list", "combos": sorted(self.combos.keys()), "active": self.active_combo_name}) + self._send({ + "type": "combo_list", + "combos": sorted(self.combos.keys()), + "active": self.active_combo_name, + "overview": ui.combo_overview(self), + }) # ------------------------- # Core state machine diff --git a/combo_engine_ui.py b/combo_engine_ui.py index 859f41d..5abf6ae 100644 --- a/combo_engine_ui.py +++ b/combo_engine_ui.py @@ -39,6 +39,32 @@ def stats_text(engine) -> str: ) +def combo_overview(engine) -> list[dict[str, Any]]: + """One row per saved combo (team, attempt counts, best/average) for the combo picker and History page.""" + rows: list[dict[str, Any]] = [] + for name in sorted(engine.combos.keys()): + rec = engine.combo_stats.get(name) + rec = rec if isinstance(rec, dict) else {} + success = int(rec.get("success", 0) or 0) + fail = int(rec.get("fail", 0) or 0) + total_ms = int(rec.get("total_success_ms", 0) or 0) + rows.append({ + "name": name, + "team_id": engine.ww.combo_ww_team.get(name, ""), + "steps": len(engine.combos.get(name) or []), + "success": success, + "fail": fail, + "best_ms": rec.get("best_ms"), + "avg_ms": (total_ms / success) if success > 0 and total_ms > 0 else None, + "target_ms": engine.combo_expected_ms.get(name), + }) + return rows + + +def stat_update_payload(engine) -> dict[str, Any]: + return {"type": "stat_update", "stats": stats_text(engine), "overview": combo_overview(engine)} + + def failures_by_step(engine) -> dict[str, int]: return combo_analytics.failures_by_step(engine) @@ -678,6 +704,7 @@ def init_payload(engine) -> dict[str, Any]: "type": "init", "combos": sorted(engine.combos.keys()), "active_combo": engine.active_combo_name, + "overview": combo_overview(engine), "no_fail_mode": getattr(engine, "no_fail_mode", False), "status": {"text": st.text, "color": st.color}, "stats": stats_text(engine), diff --git a/combos.json b/combos.json index d1f3049..6181d25 100644 --- a/combos.json +++ b/combos.json @@ -1,6 +1,6 @@ { "version": 1, - "last_active_combo": "Hiyuki Lucilla Suisui", + "last_active_combo": "Agusta, Iuno, Shorekeeper Double intro", "no_fail_mode": true, "macro_start_key": "f", "macro_stop_key": "esc", @@ -751,7 +751,7 @@ "slot3": "suisui" } }, - "ww_active_team_id": "8f6a67b57d", + "ww_active_team_id": "a8e3fa33e9", "combo_ww_team": { "Aemeath, Lynae, Mornye": "3fab25fc2a", "Agusta, Iuno, Shorekeeper Double intro": "a8e3fa33e9", diff --git a/static/app.js b/static/app.js deleted file mode 100644 index b272c67..0000000 --- a/static/app.js +++ /dev/null @@ -1,3151 +0,0 @@ -// WebSocket connection (opened by connectWebSocket() at the end of this file) -const WS_URL = 'ws://localhost:8765'; -const WS_RECONNECT_MAX_MS = 5000; -let ws = null; -let wsReconnectDelayMs = 500; - -const getEl = (id) => document.getElementById(id); - -/** Waits with duration <= this (ms) get class "short-wait" and a duller yellow border in CSS. Search for "short-wait" in style.css. */ -const SHORT_WAIT_MS = 150; - -// Timeline-only view for OBS (Browser Source or Window Capture) -if (new URLSearchParams(window.location.search).get('view') === 'timeline') { - document.title = 'WuWa Combo Tracker – Timeline'; - document.body.classList.add('timeline-window-view'); -} - -function getTimelineUrl() { - const path = window.location.pathname || '/'; - return window.location.origin + path + (path.includes('?') ? '&' : '?') + 'view=timeline'; -} - -/** Connect to the backend, retrying with backoff so the UI (and OBS overlays) recover on their own after a restart. */ -function connectWebSocket() { - const socket = new WebSocket(WS_URL); - ws = socket; - socket.onopen = () => { - console.log('Connected to WuWa Combo Tracker backend'); - wsReconnectDelayMs = 500; - updateStatus('Status: Ready', 'neutral'); - }; - socket.onclose = () => { - if (ws !== socket) return; - console.warn(`Backend connection lost; retrying in ${wsReconnectDelayMs}ms`); - updateStatus('Backend disconnected. Reconnecting…', 'fail'); - setTimeout(connectWebSocket, wsReconnectDelayMs); - wsReconnectDelayMs = Math.min(wsReconnectDelayMs * 2, WS_RECONNECT_MAX_MS); - }; - socket.onmessage = (event) => { - const msg = JSON.parse(event.data); - appState.batchQueue.push(msg); - if (!appState.isProcessingBatch) processBatch(); - }; -} - -function sendMessage(type, payload = {}) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify({ type, ...payload })); -} - -/** Per-browser display preference; storage can be unavailable (private windows, OBS), so fall back quietly. */ -function readStoredFlag(key, fallback) { - try { - const v = localStorage.getItem(key); - return v === null ? fallback : v === '1'; - } catch (_) { - return fallback; - } -} - -function writeStoredFlag(key, value) { - try { localStorage.setItem(key, value ? '1' : '0'); } catch (_) { /* ignore */ } -} - -/** Character names for team slots '1'/'2'/'3' of the selected team. */ -function wwSlotNames() { - const out = {}; - WW_SLOTS.forEach((slot, i) => { - const key = (appState.wwTeamSlots[i] || '').toString(); - const ch = key ? appState.wwCharacters[key] : null; - if (ch && ch.name) out[slot] = ch.name; - }); - return out; -} - -// Single app state (replaces scattered globals) -const appState = { - stepDisplayMode: 'images', - keyImages: {}, - lastTimelineSteps: null, - lastFailByStep: {}, - showFailCount: false, - collapseChainedPresses: true, - showMoveNames: readStoredFlag('showMoveNames', true), - autoScrollEnabled: false, - stepEditMode: true, - editStepsUndoStack: [], - targetGame: 'wuthering_waves', - wwAbilityImages: { "1": {}, "2": {}, "3": {} }, - wwSwapImages: { "1": "", "2": "", "3": "" }, - wwLmbImages: { "1": "", "2": "", "3": "" }, - wwDashImage: "", - wwTeams: [], - wwTeamId: '', - wwTeamSlots: ["", "", ""], - wwCharacters: {}, - wwCurrentChar: null, - batchQueue: [], - isProcessingBatch: false, - avgStepMsByPosition: [], -}; - -// UI Initialization -function initializeUI(data) { - const selector = getEl('comboSelector'); - selector.innerHTML = ''; - data.combos.forEach(name => { - const opt = document.createElement('option'); - opt.value = name; - opt.textContent = name; - selector.appendChild(opt); - }); - if (data.active_combo) { - selector.value = data.active_combo; - } - - // Clear live tables on init (fresh UI state) - getEl('resultsBody').innerHTML = ''; - - if (data.fail_by_step) appState.lastFailByStep = data.fail_by_step; - - const isNewCombo = data.editor && (data.editor.name || '').toString().trim() === ''; - const preserved = isNewCombo ? { - targetGame: appState.targetGame, - wwTeamId: appState.wwTeamId, - stepDisplayMode: appState.stepDisplayMode, - noFailMode: !!getEl('noFailMode')?.checked, - stepEditMode: !!getEl('stepEditToggle')?.checked, - collapseChainedPresses: !!getEl('collapseChainsToggle')?.checked, - keyImages: { ...appState.keyImages }, - } : null; - - if (data.editor) setEditorFields(data.editor); - if (preserved) { - appState.targetGame = preserved.targetGame; - appState.wwTeamId = preserved.wwTeamId; - appState.stepDisplayMode = preserved.stepDisplayMode; - const gameSelect = getEl('targetGameSelect'); - if (gameSelect) gameSelect.value = appState.targetGame; - const teamSelect = getEl('wwTeamSelect'); - if (teamSelect) teamSelect.value = appState.wwTeamId; - const stepToggle = getEl('stepDisplayToggle'); - if (stepToggle) stepToggle.checked = (appState.stepDisplayMode === 'images'); - const noFailEl = getEl('noFailMode'); - if (noFailEl) noFailEl.checked = preserved.noFailMode; - appState.stepEditMode = preserved.stepEditMode; - const stepEditToggle = getEl('stepEditToggle'); - if (stepEditToggle) stepEditToggle.checked = preserved.stepEditMode; - appState.collapseChainedPresses = preserved.collapseChainedPresses; - const collapseChainsToggle = getEl('collapseChainsToggle'); - if (collapseChainsToggle) collapseChainsToggle.checked = preserved.collapseChainedPresses; - appState.keyImages = preserved.keyImages; - syncGameUIVisibility(); - refreshTimelineIfLoaded(); - } - if (data.status) updateStatus(data.status.text, data.status.color); - if (data.stats !== undefined) updateStats(data.stats); - if (data.min_time !== undefined) updateMinTime(data.min_time); - if (data.difficulty !== undefined) updateDifficulty(data.difficulty); - if (data.user_difficulty !== undefined) updateUserDifficulty(data.user_difficulty); - if (data.apm !== undefined) updateAPM(data.apm); - if (data.apm_max !== undefined) updateAPMMax(data.apm_max); - setDifficultyColor(getEl('difficultyDisplay'), data.difficulty_value); - setDifficultyColor(getEl('userDifficultyDisplay'), data.user_difficulty_value); - if (data.timeline) updateTimeline(data.timeline); - - const noFailEl = getEl('noFailMode'); - if (noFailEl && !preserved) noFailEl.checked = !!data.no_fail_mode; - - const transcribeValidKeysEl = getEl('transcribeValidKeys'); - if (transcribeValidKeysEl && data.transcribe_valid_keys !== undefined) transcribeValidKeysEl.value = data.transcribe_valid_keys || ''; - const transcribeStartKeyEl = getEl('transcribeStartKey'); - if (transcribeStartKeyEl && data.transcribe_start_key !== undefined) transcribeStartKeyEl.value = data.transcribe_start_key || ''; - - const stripToggle = getEl('transcribeStripWaitToggle'); - if (stripToggle && data.transcribe_strip_wait_under_enabled !== undefined) { - stripToggle.checked = !!data.transcribe_strip_wait_under_enabled; - } - const stripMs = getEl('transcribeStripWaitMs'); - if (stripMs && data.transcribe_strip_wait_under_ms !== undefined) { - stripMs.value = data.transcribe_strip_wait_under_ms || ''; - } - - const macroStartKeyEl = getEl('macroStartKey'); - if (macroStartKeyEl && data.macro_start_key !== undefined) macroStartKeyEl.value = data.macro_start_key || ''; - const macroStopKeyEl = getEl('macroStopKey'); - if (macroStopKeyEl && data.macro_stop_key !== undefined) macroStopKeyEl.value = data.macro_stop_key || ''; - const macroSpamIntervalEl = getEl('macroSpamIntervalMs'); - if (macroSpamIntervalEl && data.macro_spam_interval_ms !== undefined) { - macroSpamIntervalEl.value = String(data.macro_spam_interval_ms || ''); - } -} - -// ComboTracker is Wuthering Waves only; every combo uses the WW team/character features. -function normalizeTargetGame(_v) { - return 'wuthering_waves'; -} - -function ensureWwAbilityShape(obj) { - const out = { "1": {}, "2": {}, "3": {} }; - if (!obj || typeof obj !== 'object') return out; - ['1', '2', '3'].forEach(c => { - if (obj[c] && typeof obj[c] === 'object') { - ['e', 'q', 'r'].forEach(a => { - const url = (obj[c][a] || '').toString().trim(); - if (url) out[c][a] = url; - }); - } - }); - return out; -} - -function ensureWwSlotShape(obj) { - const out = { "1": "", "2": "", "3": "" }; - if (!obj || typeof obj !== 'object') return out; - ['1', '2', '3'].forEach(k => { - const url = (obj[k] || '').toString().trim(); - if (url) out[k] = url; - }); - return out; -} - -function syncGameUIVisibility() { - const isWW = appState.targetGame === 'wuthering_waves'; - - const wwPanels = getEl('wwPanels'); - if (wwPanels) wwPanels.classList.toggle('hidden', !isWW); - - const keyDetails = getEl('keyImagesDetails'); - if (keyDetails) keyDetails.classList.toggle('hidden', isWW); - - if (isWW) { - renderWwTeamEditor(); - renderWwCharacterEditor(); - renderWwDashPreview(); - } - - // Re-render key images editor (generic mode only). - renderKeyImagesEditor(); -} - -// ----- WW helper: image preview ----- -function wwSetPreview(el, val) { - const v = (val || '').toString().trim(); - if (!v) { el.innerHTML = ''; el.style.display = 'none'; return; } - el.style.display = 'flex'; - el.style.alignItems = 'center'; - el.style.justifyContent = 'center'; - if (/^https?:\/\//i.test(v)) { - el.innerHTML = ``; - } else { - el.innerHTML = `${escapeHtml(v)}`; - } -} - -function wwSetSlotPortrait(el, char) { - el.replaceChildren(); - const value = (char?.swap_image || '').toString().trim(); - el.classList.toggle('ww-slot-portrait-empty', !value); - el.title = char?.name ? `${char.name} portrait` : 'Empty character slot'; - - if (!value) return; - if (/^https?:\/\//i.test(value)) { - const img = document.createElement('img'); - img.src = value; - img.alt = char?.name ? `${char.name} portrait` : 'Character portrait'; - img.loading = 'lazy'; - img.referrerPolicy = 'no-referrer'; - img.draggable = false; - el.appendChild(img); - } else { - const emoji = document.createElement('span'); - emoji.textContent = value; - el.appendChild(emoji); - } -} - -/** Team display names that still reference this character (by name_key, case-insensitive). */ -function wwTeamNamesReferencingCharacter(nameKey) { - const key = (nameKey || '').toString().trim().toLowerCase(); - if (!key) return []; - const names = []; - (appState.wwTeams || []).forEach(t => { - if (!t || typeof t !== 'object') return; - const slots = [t.slot1, t.slot2, t.slot3].map(s => (s || '').toString().trim().toLowerCase()); - if (slots.includes(key)) names.push((t.name || t.id || '').toString() || 'Team'); - }); - return names; -} - -// ----- WW Dash preview ----- -function renderWwDashPreview() { - const input = getEl('wwDashImageInput'); - const preview = getEl('wwDashPreview'); - if (input && preview) { - input.value = (appState.wwDashImage || '').toString(); - wwSetPreview(preview, input.value); - } -} - -// ----- WW Teams editor ----- -function renderWwTeamEditor() { - // Populate team select dropdown - const teamSelect = getEl('wwTeamSelect'); - if (teamSelect) { - const prev = teamSelect.value; - teamSelect.innerHTML = ''; - appState.wwTeams.forEach(t => { - const opt = document.createElement('option'); - opt.value = t.id; - opt.textContent = t.name; - teamSelect.appendChild(opt); - }); - teamSelect.value = prev || appState.wwTeamId; - } - - // Build slot rows - const slotsContainer = getEl('wwTeamSlots'); - if (!slotsContainer) return; - slotsContainer.innerHTML = ''; - - const charOptions = () => { - const empty = ''; - const chars = Object.values(appState.wwCharacters) - .filter(c => c && c.name) - .sort((a, b) => (a.name || '').localeCompare(b.name || '')); - return empty + chars.map(c => ``).join(''); - }; - - let dragSrcIdx = null; - - appState.wwTeamSlots.forEach((charKey, idx) => { - const row = document.createElement('div'); - row.className = 'ww-slot-row'; - row.draggable = true; - row.dataset.idx = idx; - - const handle = document.createElement('span'); - handle.className = 'ww-slot-handle'; - handle.textContent = '⠿'; - handle.title = 'Drag to reorder'; - - const label = document.createElement('span'); - label.className = 'ww-slot-label'; - label.textContent = `Slot ${idx + 1}`; - - const portrait = document.createElement('div'); - portrait.className = 'ww-slot-portrait'; - wwSetSlotPortrait(portrait, charKey ? appState.wwCharacters[charKey] : null); - - const sel = document.createElement('select'); - sel.className = 'ww-slot-char-select'; - sel.innerHTML = charOptions(); - sel.value = charKey || ''; - - sel.addEventListener('change', () => { - appState.wwTeamSlots[idx] = sel.value; - wwSetSlotPortrait(portrait, sel.value ? appState.wwCharacters[sel.value] : null); - _resolveTeamImagesToState(); - refreshTimelineIfLoaded(); - }); - - row.appendChild(handle); - row.appendChild(label); - row.appendChild(portrait); - row.appendChild(sel); - slotsContainer.appendChild(row); - - row.addEventListener('dragstart', e => { - dragSrcIdx = idx; - e.dataTransfer.effectAllowed = 'move'; - setTimeout(() => row.classList.add('ww-drag-active'), 0); - }); - row.addEventListener('dragend', () => { - dragSrcIdx = null; - row.classList.remove('ww-drag-active'); - slotsContainer.querySelectorAll('.ww-slot-row').forEach(r => r.classList.remove('ww-drag-over')); - }); - row.addEventListener('dragover', e => { - if (dragSrcIdx !== null && dragSrcIdx !== idx) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - slotsContainer.querySelectorAll('.ww-slot-row').forEach(r => r.classList.remove('ww-drag-over')); - row.classList.add('ww-drag-over'); - } - }); - row.addEventListener('dragleave', e => { - if (!row.contains(e.relatedTarget)) row.classList.remove('ww-drag-over'); - }); - row.addEventListener('drop', e => { - e.preventDefault(); - const src = dragSrcIdx; - if (src !== null && src !== idx) { - const tmp = appState.wwTeamSlots[src]; - appState.wwTeamSlots[src] = appState.wwTeamSlots[idx]; - appState.wwTeamSlots[idx] = tmp; - renderWwTeamEditor(); - _resolveTeamImagesToState(); - refreshTimelineIfLoaded(); - } - }); - }); -} - -// Resolve current team slots → appState.wwSwapImages / wwLmbImages / wwAbilityImages for timeline -function _resolveTeamImagesToState() { - const swap = { "1": "", "2": "", "3": "" }; - const lmb = { "1": "", "2": "", "3": "" }; - const ability = { "1": {}, "2": {}, "3": {} }; - appState.wwTeamSlots.forEach((charKey, idx) => { - const sk = String(idx + 1); - const char = charKey ? appState.wwCharacters[charKey] : null; - if (!char) return; - if (char.swap_image) swap[sk] = char.swap_image; - if (char.lmb_image) lmb[sk] = char.lmb_image; - if (char.ability_images && typeof char.ability_images === 'object') { - ability[sk] = { ...char.ability_images }; - } - }); - appState.wwSwapImages = swap; - appState.wwLmbImages = lmb; - appState.wwAbilityImages = ability; -} - -// ----- WW Character editor ----- -function _buildCharRow(labelText, inputValue, onInput) { - const row = document.createElement('div'); - row.className = 'ww-ability-row'; - const label = document.createElement('span'); - label.className = 'ww-ability-label'; - label.textContent = labelText; - const input = document.createElement('input'); - input.type = 'text'; - input.placeholder = 'https://... or emoji'; - input.value = inputValue || ''; - const preview = document.createElement('div'); - preview.className = 'ww-ability-preview'; - wwSetPreview(preview, input.value); - input.addEventListener('input', () => { - onInput(input.value.trim()); - wwSetPreview(preview, input.value); - }); - row.appendChild(label); - row.appendChild(input); - row.appendChild(preview); - return row; -} - -function renderWwCharacterPicker(chars) { - const picker = getEl('wwCharPicker'); - if (!picker) return; - picker.replaceChildren(); - - const selectedKey = appState.wwCurrentChar || ''; - const selectedChar = selectedKey ? appState.wwCharacters[selectedKey] : null; - const trigger = document.createElement('button'); - trigger.type = 'button'; - trigger.className = 'ww-char-picker-trigger'; - trigger.setAttribute('aria-haspopup', 'listbox'); - trigger.setAttribute('aria-expanded', 'false'); - - const triggerPortrait = document.createElement('span'); - triggerPortrait.className = 'ww-char-picker-portrait'; - wwSetSlotPortrait(triggerPortrait, selectedChar); - const triggerLabel = document.createElement('span'); - triggerLabel.className = 'ww-char-picker-label'; - triggerLabel.textContent = selectedChar?.name || '— New Character —'; - const chevron = document.createElement('span'); - chevron.className = 'ww-char-picker-chevron'; - chevron.setAttribute('aria-hidden', 'true'); - trigger.append(triggerPortrait, triggerLabel, chevron); - - const menu = document.createElement('div'); - menu.className = 'ww-char-picker-menu'; - menu.setAttribute('role', 'listbox'); - menu.hidden = true; - - const choose = (key) => { - appState.wwCurrentChar = key || null; - renderWwCharacterEditor(); - }; - const addOption = (char) => { - const key = char ? (char.name_key || char.name.toLowerCase()) : ''; - const option = document.createElement('button'); - option.type = 'button'; - option.className = 'ww-char-picker-option'; - option.setAttribute('role', 'option'); - option.setAttribute('aria-selected', String(key === selectedKey)); - - const portrait = document.createElement('span'); - portrait.className = 'ww-char-picker-portrait'; - wwSetSlotPortrait(portrait, char); - const label = document.createElement('span'); - label.textContent = char?.name || '— New Character —'; - option.append(portrait, label); - option.addEventListener('click', () => choose(key)); - menu.appendChild(option); - }; - - addOption(null); - chars.forEach(addOption); - trigger.addEventListener('click', () => { - const isOpen = menu.hidden; - menu.hidden = !isOpen; - trigger.setAttribute('aria-expanded', String(isOpen)); - }); - trigger.addEventListener('keydown', e => { - if (e.key === 'Escape') { - menu.hidden = true; - trigger.setAttribute('aria-expanded', 'false'); - } - }); - - picker.append(trigger, menu); -} - -function renderWwCharacterEditor() { - const chars = Object.values(appState.wwCharacters) - .filter(c => c && c.name) - .sort((a, b) => (a.name || '').localeCompare(b.name || '')); - renderWwCharacterPicker(chars); - - const container = getEl('wwCharEditor'); - if (!container) return; - container.innerHTML = ''; - - const loaded = appState.wwCurrentChar ? appState.wwCharacters[appState.wwCurrentChar] : null; - - // Name row - const nameRow = document.createElement('div'); - nameRow.className = 'ww-char-name-row'; - const nameLabel = document.createElement('span'); - nameLabel.className = 'ww-ability-label'; - nameLabel.textContent = 'Name :'; - const nameInput = document.createElement('input'); - nameInput.type = 'text'; - nameInput.id = 'wwCharNameInput'; - nameInput.placeholder = 'Character name'; - nameInput.value = (loaded && loaded.name) ? loaded.name : ''; - nameRow.appendChild(nameLabel); - nameRow.appendChild(nameInput); - container.appendChild(nameRow); - - // Character (slot swap) key icon, LMB, Q, E, R rows - let swapVal = (loaded && loaded.swap_image) || ''; - let lmbVal = (loaded && loaded.lmb_image) || ''; - let abilVals = { q: '', e: '', r: '' }; - if (loaded && loaded.ability_images) { - abilVals.q = loaded.ability_images.q || ''; - abilVals.e = loaded.ability_images.e || ''; - abilVals.r = loaded.ability_images.r || ''; - } - - container.appendChild(_buildCharRow('Character :', swapVal, v => { swapVal = v; })); - container.appendChild(_buildCharRow('LMB :', lmbVal, v => { lmbVal = v; })); - container.appendChild(_buildCharRow('Q :', abilVals.q, v => { abilVals.q = v; })); - container.appendChild(_buildCharRow('E :', abilVals.e, v => { abilVals.e = v; })); - container.appendChild(_buildCharRow('R :', abilVals.r, v => { abilVals.r = v; })); - - // Save & Delete buttons - const btnRow = document.createElement('div'); - btnRow.className = 'ww-char-btn-row'; - - const saveBtn = document.createElement('button'); - saveBtn.type = 'button'; - saveBtn.textContent = 'Save'; - saveBtn.addEventListener('click', () => { - const name = (nameInput.value || '').trim(); - if (!name) { updateStatus('Please enter a character name.', 'fail'); return; } - const nameKey = name.toLowerCase(); - const isNew = appState.wwCurrentChar === null; - const overwritingDifferent = !isNew && nameKey !== appState.wwCurrentChar && nameKey in appState.wwCharacters; - const overwritingExisting = isNew && nameKey in appState.wwCharacters; - const doSave = () => sendMessage('save_character', { - name, - swap_image: swapVal, - lmb_image: lmbVal, - ability_images: { q: abilVals.q, e: abilVals.e, r: abilVals.r }, - }); - if (overwritingDifferent || overwritingExisting) { - if (!confirm(`"${name}" already exists. Overwrite?`)) return; - } - doSave(); - }); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.textContent = 'Delete'; - deleteBtn.className = 'danger'; - deleteBtn.disabled = !loaded; - deleteBtn.addEventListener('click', () => { - if (!appState.wwCurrentChar) return; - const charName = (loaded && loaded.name) || appState.wwCurrentChar; - const blockingTeams = wwTeamNamesReferencingCharacter(appState.wwCurrentChar); - if (blockingTeams.length) { - alert(`Remove from these teams first: ${blockingTeams.join(', ')}`); - return; - } - if (!confirm(`Delete character "${charName}"?`)) return; - sendMessage('delete_character', { name: charName }); - }); - - btnRow.appendChild(saveBtn); - btnRow.appendChild(deleteBtn); - container.appendChild(btnRow); -} - -// Split a combo string into top-level tokens (respects (), {}, []). -function splitTopLevelTokens(str) { - const out = []; - let buf = ''; - let paren = 0, brace = 0, bracket = 0; - for (const ch of (str || '')) { - if (ch === '(') paren++; - else if (ch === ')') paren = Math.max(0, paren - 1); - else if (ch === '{') brace++; - else if (ch === '}') brace = Math.max(0, brace - 1); - else if (ch === '[') bracket++; - else if (ch === ']') bracket = Math.max(0, bracket - 1); - if (ch === ',' && paren === 0 && brace === 0 && bracket === 0) { - const t = buf.trim(); - if (t) out.push(t); - buf = ''; - } else { - buf += ch; - } - } - const t = buf.trim(); - if (t) out.push(t); - return out; -} - -// Extract the hold key from a hold(key, ...) token. -function _extractHoldKey(part) { - const tl = part.toLowerCase(); - if (!tl.startsWith('hold(') || !tl.endsWith(')')) return null; - const inner = part.slice('hold('.length, -1); - const args = splitTopLevelTokens(inner); - return args.length >= 1 ? args[0].trim().toLowerCase() : null; -} - -// Extract keys from inputs text -function extractKeysFromInputs() { - const txt = (getEl('comboInputs')?.value || '').toString(); - if (!txt.trim()) return []; - - const parts = splitTopLevelTokens(txt).map(x => x.toLowerCase()); - const keys = new Set(); - - parts.forEach(part => { - // hold(key, time) or hold(key, time, {body}) — new or existing form - if (/^hold\s*\(/i.test(part)) { - const hk = _extractHoldKey(part); - if (hk) keys.add(hk); - // Also extract keys from {body} if present - const bodyM = part.match(/\{([^}]*)\}\s*\)$/); - if (bodyM) { - splitTopLevelTokens(bodyM[1]).forEach(bi => { - const bit = bi.trim().toLowerCase(); - if (!/^wait[_a-z]*[:(\s]/i.test(bit)) keys.add(bit); - }); - } - return; - } - // key{time} shorthand - let m = part.match(/^([^{]+)\{/); - if (m) { - keys.add(m[1].trim()); - return; - } - // wait(...) -> skip - if (/^wait[_a-z]*[:(\s]/i.test(part)) return; - // [group] -> extract items using top-level split - m = part.match(/^\[(.+)\]$/s); - if (m) { - splitTopLevelTokens(m[1]).forEach(gi => { - const gg = gi.trim().toLowerCase(); - if (/^hold\s*\(/i.test(gg)) { - const hk = _extractHoldKey(gg); - if (hk) keys.add(hk); - return; - } - let mm = gg.match(/^([^{]+)\{/); - if (mm) { keys.add(mm[1].trim()); return; } - mm = gg.match(/^wait\(\s*([^,]+)\s*,/i); - if (mm) { keys.add(mm[1].trim()); return; } - if (!/^wait[_a-z]*[:(\s]/i.test(gg)) keys.add(gg); - }); - return; - } - // Plain key - keys.add(part); - }); - - return Array.from(keys).sort(); -} - -function readKeyImagesFromUI() { - const container = getEl('keyImagesEditor'); - if (!container) return; - const inputs = container.querySelectorAll('input[data-key]'); - const next = {}; - inputs.forEach(inp => { - const k = (inp.getAttribute('data-key') || '').trim().toLowerCase(); - const url = (inp.value || '').toString().trim(); - if (k && url) next[k] = url; - }); - appState.keyImages = next; -} - -function renderKeyImagesEditor() { - // Generic mode only - if (appState.targetGame === 'wuthering_waves') return; - - readKeyImagesFromUI(); - const container = getEl('keyImagesEditor'); - if (!container) return; - container.innerHTML = ''; - - const keys = extractKeysFromInputs(); - if (keys.length === 0) { - container.innerHTML = '
Enter inputs above to see key image fields.
'; - return; - } - - keys.forEach(k => { - const row = document.createElement('div'); - row.className = 'key-image-row'; - - const label = document.createElement('span'); - label.className = 'key-image-label'; - label.textContent = k.toUpperCase(); - - const input = document.createElement('input'); - input.type = 'text'; - input.setAttribute('data-key', k); - input.placeholder = 'https://... or emoji'; - input.value = (appState.keyImages[k] || '').toString(); - - const preview = document.createElement('div'); - preview.className = 'key-image-preview'; - const v = (appState.keyImages[k] || '').toString().trim(); - if (v) { - preview.style.display = 'flex'; - if (/^https?:\/\//i.test(v)) { - preview.innerHTML = ``; - } else { - preview.innerHTML = `${escapeHtml(v)}`; - } - } else { - preview.style.display = 'none'; - } - - input.addEventListener('input', () => { - const url = input.value.trim(); - if (url) { - appState.keyImages[k] = url; - preview.style.display = 'flex'; - if (/^https?:\/\//i.test(url)) { - preview.innerHTML = ``; - } else { - preview.innerHTML = `${escapeHtml(url)}`; - } - } else { - delete appState.keyImages[k]; - preview.style.display = 'none'; - } - }); - - row.appendChild(label); - row.appendChild(input); - row.appendChild(preview); - container.appendChild(row); - }); -} - -// Demo video: normalize YouTube link to embed URL -function getYouTubeEmbedUrl(url) { - const s = (url || '').toString().trim(); - if (!s) return null; - try { - // youtu.be/VIDEO_ID - const short = s.match(/youtu\.be\/([a-zA-Z0-9_-]{10,})/); - if (short) return 'https://www.youtube.com/embed/' + short[1]; - // youtube.com/watch?v=VIDEO_ID or youtube.com/embed/VIDEO_ID - const u = new URL(s.startsWith('http') ? s : 'https://' + s); - if (u.hostname.replace(/^www\./, '') === 'youtube.com') { - const v = u.searchParams.get('v') || (u.pathname || '').split('/').pop(); - if (v && /^[a-zA-Z0-9_-]{10,}$/.test(v)) return 'https://www.youtube.com/embed/' + v; - } - } catch (_) {} - return null; -} - -function updateDemoVideoEmbed(url) { - const wrap = getEl('demoVideoEmbedWrap'); - const iframe = getEl('demoVideoEmbed'); - if (!wrap || !iframe) return; - const embedUrl = getYouTubeEmbedUrl(url); - if (embedUrl) { - iframe.src = embedUrl; - wrap.classList.remove('hidden'); - } else { - iframe.src = ''; - wrap.classList.add('hidden'); - } -} - -// Editor fields update (from backend) -function setEditorFields(data) { - getEl('comboName').value = data.name || ''; - const inputsEl = getEl('comboInputs'); - if (inputsEl) { - inputsEl.value = data.inputs || ''; - if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); - } - getEl('comboEnders').value = data.enders || ''; - getEl('comboExpectedTime').value = data.expected_time || ''; - getEl('comboUserDifficulty').value = data.user_difficulty || ''; - - const demoVideoEl = getEl('comboDemoVideo'); - if (demoVideoEl) { - demoVideoEl.value = (data.demo_video || '').toString().trim(); - updateDemoVideoEmbed(demoVideoEl.value); - } - - appState.stepDisplayMode = (data.step_display_mode || 'images').toString().trim().toLowerCase(); - if (!['icons', 'images'].includes(appState.stepDisplayMode)) appState.stepDisplayMode = 'images'; - const toggle = getEl('stepDisplayToggle'); - if (toggle) toggle.checked = (appState.stepDisplayMode === 'images'); - - appState.keyImages = (typeof data.key_images === 'object' && data.key_images !== null) ? { ...data.key_images } : {}; - - // Target game & WW data - appState.targetGame = normalizeTargetGame(data.target_game || 'generic'); - const gameSelect = getEl('targetGameSelect'); - if (gameSelect) gameSelect.value = appState.targetGame; - - // WW character library - const charsList = Array.isArray(data.ww_characters) ? data.ww_characters : []; - appState.wwCharacters = {}; - charsList.forEach(c => { - if (c && c.name_key) appState.wwCharacters[c.name_key] = c; - }); - - // WW teams list (now includes slot1/slot2/slot3) - appState.wwTeams = Array.isArray(data.ww_teams) ? [...data.ww_teams] : []; - - // Selected team - appState.wwTeamId = (data.ww_team_id || '').toString().trim(); - const teamNameEl = getEl('wwTeamName'); - if (teamNameEl) teamNameEl.value = (data.ww_team_name || '').toString(); - - // Team slots - const slots = data.ww_team_slots || {}; - appState.wwTeamSlots = [ - (slots.slot1 || '').toString(), - (slots.slot2 || '').toString(), - (slots.slot3 || '').toString(), - ]; - - // Global dash - appState.wwDashImage = (data.ww_dash_image || data.ww_team_dash_image || '').toString().trim(); - - // Resolved images for timeline rendering - appState.wwSwapImages = ensureWwSlotShape(data.ww_team_swap_images); - appState.wwLmbImages = ensureWwSlotShape(data.ww_team_lmb_images); - appState.wwAbilityImages = ensureWwAbilityShape(data.ww_team_ability_images); - - // Keep the character editor selection across WW refreshes (e.g. changing team sends - // combo_data). Only clear if that character no longer exists in the library. - const prevCharKey = appState.wwCurrentChar; - if (prevCharKey && !appState.wwCharacters[prevCharKey]) { - appState.wwCurrentChar = null; - } - - syncGameUIVisibility(); -} - -// Status display -function updateStatus(text, color) { - const el = getEl('statusDisplay'); - if (!el) return; - el.textContent = text || 'Status: Ready'; - el.className = 'status-' + (color || 'neutral'); -} - -// Stats: the backend sends "Label: value" texts; cards show just the value. -function setStatValue(id, text) { - const el = getEl(id)?.querySelector('.stat-value'); - if (!el) return; - const raw = (text || '').toString(); - const i = raw.indexOf(':'); - const value = (i >= 0 ? raw.slice(i + 1) : raw).trim(); - el.textContent = value || '—'; - el.title = raw; -} - -// e.g. "Stats: 3 success / 1 fail (75%) | Best: 12.3s | Avg: 13.0s" -function updateStats(text) { - const parts = (text || '').toString().replace(/^Stats:\s*/, '').split('|').map(s => s.trim()); - const result = (parts[0] || '').replace(/ success \/ /, ' / ').replace(/ fail/, ''); - setStatValue('statsResult', /^0 \/ 0\b/.test(result) ? '' : result); // no attempts yet - setStatValue('statsBest', parts[1] || ''); - setStatValue('statsAvg', parts[2] || ''); -} - -function updateMinTime(text) { - // Drop the "(53700ms)" repeat of the value to keep the card short. - setStatValue('minTimeDisplay', (text || '').toString().replace(/\s*\(\d+ms\)\s*$/, '')); -} - -function updateDifficulty(text) { - setStatValue('difficultyDisplay', text); -} - -function updateUserDifficulty(text) { - setStatValue('userDifficultyDisplay', text); -} - -function updateAPM(text) { - setStatValue('apmDisplay', text); -} - -function updateAPMMax(text) { - setStatValue('apmMaxDisplay', text); -} - -function setDifficultyColor(el, value) { - if (!el) return; - el.classList.remove('diff-easy', 'diff-med', 'diff-hard', 'diff-insane'); - if (value === null || value === undefined || value === '') return; // unrated, not "easy" - const v = Number(value); - if (!Number.isFinite(v)) return; - if (v < 3) el.classList.add('diff-easy'); - else if (v < 6) el.classList.add('diff-med'); - else if (v < 8) el.classList.add('diff-hard'); - else el.classList.add('diff-insane'); -} - -// Attempt log -const LOG_ATTEMPTS_STORAGE_KEY = 'logAttemptsEnabled'; -let logAttemptsEnabled = false; - -function isLogAttemptsEnabled() { - return logAttemptsEnabled; -} - -function setLogAttemptsEnabled(enabled) { - logAttemptsEnabled = !!enabled; - try { - localStorage.setItem(LOG_ATTEMPTS_STORAGE_KEY, logAttemptsEnabled ? '1' : '0'); - } catch (_) { - // localStorage unavailable; runtime-only is fine. - } - const resultsTable = getEl('resultsTable'); - if (resultsTable) resultsTable.classList.toggle('hidden', !logAttemptsEnabled); - renderAvgSplitsOnTimeline(); -} - -function clearAttemptLog() { - getEl('resultsBody').innerHTML = ''; - appState.avgStepMsByPosition = []; - renderAvgSplitsOnTimeline(); -} - -function escapeMarkdownCell(text) { - return (text || '').toString().replace(/\|/g, '\\|'); -} - -function buildAttemptMarkdownTable(separatorRow) { - if (!separatorRow) return ''; - const lines = [ - '| Input | Step Time (ms) | Total (ms) | Avg Step Time (ms) |', - '| ----- | ---------- | ---------- | -------------- |', - ]; - - let cur = separatorRow.nextElementSibling; - while (cur) { - if (cur.classList.contains('separator')) break; - if (cur.classList.contains('result-row')) { - const cells = cur.querySelectorAll('span'); - if (cells.length >= 4) { - const input = escapeMarkdownCell(cells[0].textContent?.trim() || ''); - const split = escapeMarkdownCell(cells[1].textContent?.trim() || '—'); - const total = escapeMarkdownCell(cells[2].textContent?.trim() || '—'); - const avgSplit = escapeMarkdownCell(cells[3].textContent?.trim() || '—'); - lines.push(`| ${input} | ${split} | ${total} | ${avgSplit} |`); - } - } - cur = cur.nextElementSibling; - } - - return lines.length > 2 ? lines.join('\n') : ''; -} - -async function copyAttemptToClipboard(separatorRow, copyBtn) { - const markdown = buildAttemptMarkdownTable(separatorRow); - if (!markdown) return; - try { - await navigator.clipboard.writeText(markdown); - } catch (_) { - const ta = document.createElement('textarea'); - ta.value = markdown; - ta.style.position = 'fixed'; - ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - } - if (copyBtn) { - const originalTitle = copyBtn.title; - copyBtn.title = 'Copied!'; - setTimeout(() => { - copyBtn.title = originalTitle; - }, 1200); - } -} - -function recalcAttemptAvgSplits() { - const body = getEl('resultsBody'); - if (!body) return; - - // Group result rows by attempt (separated by .separator divs) - const attempts = []; - let current = null; - let el = body.firstElementChild; - while (el) { - if (el.classList.contains('separator')) { - current = []; - attempts.push(current); - } else if (el.classList.contains('result-row') && current !== null) { - current.push(el); - } - el = el.nextElementSibling; - } - - const maxLen = attempts.reduce((m, a) => Math.max(m, a.length), 0); - const avgByPos = []; - - for (let pos = 0; pos < maxLen; pos++) { - const splits = []; - for (const attempt of attempts) { - if (pos < attempt.length) { - const cells = attempt[pos].querySelectorAll('span'); - const val = parseFloat(cells[1]?.textContent?.trim()); - if (Number.isFinite(val)) splits.push(val); - } - } - const avg = splits.length > 0 - ? splits.reduce((s, v) => s + v, 0) / splits.length - : null; - avgByPos.push(avg); - - const avgText = avg !== null ? avg.toFixed(1) : '—'; - for (const attempt of attempts) { - if (pos < attempt.length) { - const avgCell = attempt[pos].querySelector('.result-avg-split'); - if (avgCell) avgCell.textContent = avgText; - } - } - } - - appState.avgStepMsByPosition = avgByPos; - renderAvgSplitsOnTimeline(); -} - -function renderAvgSplitsOnTimeline() { - const container = getEl('comboTimeline'); - if (!container) return; - - // Clear all existing avg overlays - container.querySelectorAll('.step-avg-split').forEach(e => e.remove()); - - if (!appState.showFailCount) return; - - const avgs = appState.avgStepMsByPosition || []; - if (avgs.length === 0) return; - - // Top-level tiles in DOM order (skip chain-dash connectors) - const tiles = [...container.children].filter( - e => !e.classList.contains('timeline-chain-dash') - ); - - const parseStepIndices = (tile, fallbackIdx) => { - const raw = (tile.dataset.stepIndices || '').trim(); - if (!raw) return [fallbackIdx]; - const parsed = raw.split(',') - .map(v => Number.parseInt(v, 10)) - .filter(v => Number.isFinite(v) && v >= 0); - return parsed.length > 0 ? parsed : [fallbackIdx]; - }; - - tiles.forEach((tile, idx) => { - const indices = parseStepIndices(tile, idx); - const values = indices - .map(i => avgs[i]) - .filter(v => Number.isFinite(v)); - if (values.length === 0) return; - const tileAvg = values.reduce((s, v) => s + v, 0); - const span = document.createElement('span'); - span.className = 'step-avg-split'; - span.textContent = `avg step ${tileAvg.toFixed(1)}ms`; - tile.appendChild(span); - }); -} - -function deleteAttemptBlock(separatorRow) { - if (!separatorRow || !separatorRow.parentElement) return; - const toRemove = [separatorRow]; - let cur = separatorRow.nextElementSibling; - while (cur) { - if (cur.classList.contains('separator')) break; - toRemove.push(cur); - cur = cur.nextElementSibling; - } - toRemove.forEach((el) => el.remove()); - recalcAttemptAvgSplits(); -} - -function addAttemptSeparator(name, attempt) { - if (!isLogAttemptsEnabled()) return; - const body = getEl('resultsBody'); - if (!body) return; - const row = document.createElement('div'); - row.className = 'result-row separator'; - - const label = document.createElement('span'); - label.className = 'attempt-separator-label'; - label.textContent = `—— ${name} | Attempt ${attempt} ——`; - - const copyBtn = document.createElement('button'); - copyBtn.type = 'button'; - copyBtn.className = 'attempt-copy-btn subtle icon-btn'; - copyBtn.title = 'Copy this attempt as table'; - copyBtn.setAttribute('aria-label', 'Copy this attempt as table'); - copyBtn.innerHTML = ''; - copyBtn.addEventListener('click', () => copyAttemptToClipboard(row, copyBtn)); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.className = 'attempt-delete-btn danger subtle icon-btn'; - deleteBtn.title = 'Delete this attempt from log'; - deleteBtn.setAttribute('aria-label', 'Delete this attempt from log'); - deleteBtn.innerHTML = ''; - deleteBtn.addEventListener('click', () => deleteAttemptBlock(row)); - - row.appendChild(label); - row.appendChild(copyBtn); - row.appendChild(deleteBtn); - body.appendChild(row); - scrollToBottom('resultsTable'); -} - -function addResultRow(data) { - if (!isLogAttemptsEnabled()) return; - const body = getEl('resultsBody'); - if (!body) return; - const row = document.createElement('div'); - row.className = 'result-row'; - - const stepMs = (data.step_ms != null) ? data.step_ms : data.split_ms; - if (data.fail === true || stepMs === 'FAIL' || data.total_ms === 'FAIL') { - row.classList.add('fail'); - } else { - row.classList.add('success'); - } - - row.innerHTML = ` - ${escapeHtml(data.input || '')} - ${stepMs != null ? stepMs : '—'} - ${data.total_ms != null ? data.total_ms : '—'} - — - `; - - body.appendChild(row); - recalcAttemptAvgSplits(); - scrollToBottom('resultsTable'); -} - -// Hold / Wait animations (global UI state, not per-combo) -let holdAnim = { active: false, requiredMs: 0, startedAt: 0 }; -let holdRafId = null; - -let waitAnim = { active: false, requiredMs: 0, startedAt: 0 }; -let waitRafId = null; - -let spamAnim = { active: false, requiredMs: 0, startedAt: 0 }; -let spamRafId = null; - -function stopHoldAnimation() { - holdAnim.active = false; - if (holdRafId !== null) { - cancelAnimationFrame(holdRafId); - holdRafId = null; - } -} - -function tickHoldAnimation() { - if (!holdAnim.active) { - holdRafId = null; - return; - } - const stepEl = document.querySelector('.step.hold.active, .step.hold-with-body.active, .step.hold-with_body.active'); - if (!stepEl) { - // Timeline might be re-rendering; try again next frame. - holdRafId = requestAnimationFrame(tickHoldAnimation); - return; - } - - const elapsed = performance.now() - holdAnim.startedAt; - const req = Math.max(1, holdAnim.requiredMs || 1); - const pct = Math.max(0, Math.min(100, (elapsed / req) * 100)); - stepEl.style.setProperty('--hold-pct', `${pct}%`); - updateStatus(`Holding... ${Math.round(pct)}%`, 'recording'); - - if (pct >= 100) { - holdRafId = null; - return; - } - holdRafId = requestAnimationFrame(tickHoldAnimation); -} - -function startHoldAnimation(requiredMs) { - stopHoldAnimation(); - holdAnim.active = true; - holdAnim.requiredMs = Math.max(1, Number(requiredMs) || 1); - holdAnim.startedAt = performance.now(); - holdRafId = requestAnimationFrame(tickHoldAnimation); -} - -function stopWaitAnimation() { - waitAnim.active = false; - if (waitRafId !== null) { - cancelAnimationFrame(waitRafId); - waitRafId = null; - } -} - -function tickWaitAnimation() { - if (!waitAnim.active) { - waitRafId = null; - return; - } - // Select active wait steps, including hold-body wait chips. - const stepEl = document.querySelector( - '.step.wait.active, .step.press-wait.active, ' + - '.hold-body-chip.wait.active, .hold-body-chip.press-wait.active .hold-body-inline-wait' - ); - if (!stepEl) { - waitRafId = requestAnimationFrame(tickWaitAnimation); - return; - } - - const elapsed = performance.now() - waitAnim.startedAt; - const req = Math.max(1, Number(waitAnim.requiredMs) || 1); - const pct = Math.max(0, Math.min(100, (elapsed / req) * 100)); - const progressProperty = stepEl.classList.contains('hold-body-chip') - || stepEl.classList.contains('hold-body-inline-wait') - ? '--chip-wait-pct' - : '--wait-pct'; - stepEl.style.setProperty(progressProperty, `${pct}%`); - updateStatus(`Waiting... ${Math.round(pct)}%`, 'wait'); - - if (pct >= 100) { - waitRafId = null; - return; - } - waitRafId = requestAnimationFrame(tickWaitAnimation); -} - -function startWaitAnimation(requiredMs) { - stopWaitAnimation(); - waitAnim.active = true; - waitAnim.requiredMs = Math.max(1, Number(requiredMs) || 1); - waitAnim.startedAt = performance.now(); - waitRafId = requestAnimationFrame(tickWaitAnimation); -} - -function stopSpamAnimation() { - spamAnim.active = false; - if (spamRafId !== null) { - cancelAnimationFrame(spamRafId); - spamRafId = null; - } -} - -function tickSpamAnimation() { - if (!spamAnim.active) { - spamRafId = null; - return; - } - const stepEl = document.querySelector( - '.step.spam.active, .hold-body-chip.spam.active .hold-body-inline-wait' - ); - if (!stepEl) { - spamRafId = requestAnimationFrame(tickSpamAnimation); - return; - } - - const elapsed = performance.now() - spamAnim.startedAt; - const req = Math.max(1, Number(spamAnim.requiredMs) || 1); - const pct = Math.max(0, Math.min(100, (elapsed / req) * 100)); - const progressProperty = stepEl.classList.contains('hold-body-inline-wait') - ? '--chip-wait-pct' - : '--wait-pct'; - stepEl.style.setProperty(progressProperty, `${pct}%`); - updateStatus(`Spamming... ${Math.round(pct)}%`, 'wait'); - - if (pct >= 100) { - spamRafId = null; - return; - } - spamRafId = requestAnimationFrame(tickSpamAnimation); -} - -function startSpamAnimation(requiredMs) { - stopSpamAnimation(); - spamAnim.active = true; - spamAnim.requiredMs = Math.max(1, Number(requiredMs) || 1); - spamAnim.startedAt = performance.now(); - spamRafId = requestAnimationFrame(tickSpamAnimation); -} - -// --------------------------------------------------------------------------- -// Step inline editing helpers -// --------------------------------------------------------------------------- - -/** - * JS port of the Python split_inputs: splits a comma-separated inputs string - * into top-level tokens, respecting nested (, {, [ delimiters. - */ -function splitInputsTokens(str) { - const out = []; - let buf = ''; - let paren = 0, brace = 0, bracket = 0; - for (const ch of (str || '')) { - if (ch === '(') paren++; - else if (ch === ')') paren = Math.max(0, paren - 1); - else if (ch === '{') brace++; - else if (ch === '}') brace = Math.max(0, brace - 1); - else if (ch === '[') bracket++; - else if (ch === ']') bracket = Math.max(0, bracket - 1); - - if (ch === ',' && paren === 0 && brace === 0 && bracket === 0) { - const tok = buf.trim(); - if (tok) out.push(tok); - buf = ''; - continue; - } - buf += ch; - } - const last = buf.trim(); - if (last) out.push(last); - return out; -} - -/** - * Format a duration in milliseconds back to the shortest token string. - * Uses "s" suffix if divisible cleanly, otherwise "ms". - */ -function formatDurationToken(ms) { - const n = Number(ms); - if (!Number.isFinite(n) || n <= 0) return `${ms}ms`; - if (n % 1000 === 0) return `${n / 1000}s`; - if (n % 100 === 0) return `${(n / 1000).toFixed(1)}s`; - if (n % 10 === 0) return `${(n / 1000).toFixed(2)}s`; - return `${Math.round(n)}ms`; -} - -/** - * Parse a user-entered duration string (e.g. "0.23s", "230ms", "230") to ms. - * Returns null if unparseable. - */ -function parseDurationToMs(raw) { - let s = (raw || '').trim().toLowerCase(); - if (!s) return null; - // Strip trailing UI markers (e.g. hold-with-body status checkmark) - s = s.replace(/\s*✓\s*$/u, '').trim(); - if (s.endsWith('ms')) { - const v = parseFloat(s.slice(0, -2)); - return Number.isFinite(v) && v > 0 ? Math.round(v) : null; - } - if (s.endsWith('s')) { - const v = parseFloat(s.slice(0, -1)); - return Number.isFinite(v) && v > 0 ? Math.round(v * 1000) : null; - } - const v = parseFloat(s); - if (Number.isFinite(v) && v > 0) { - // Treat as seconds if it looks fractional, ms otherwise. - return s.includes('.') ? Math.round(v * 1000) : Math.round(v); - } - return null; -} - -/** - * Reconstruct source token(s) for a step after an inline field edit. - * Returns an array of 1 or 2 token strings, or null on invalid input. - * - * `field` is either 'key' or 'duration'. - * `newValue` is the raw string the user typed. - * `s` is the step data dict from the backend. - * `oldSourceToken` — original combo-input token when rebuilding complex holds (hold-with-body). - */ -function extractHoldWithBodyParts(oldSourceToken) { - const t = (oldSourceToken || '').trim(); - if (!t.toLowerCase().startsWith('hold(') || !t.endsWith(')')) return null; - const inner = t.slice(5, -1); - const braceIdx = inner.indexOf('{'); - if (braceIdx === -1) return null; - let head = inner.slice(0, braceIdx).replace(/,\s*$/, '').trim(); - const bodyPart = inner.slice(braceIdx).trim(); - const commaIdx = head.indexOf(','); - if (commaIdx === -1) return null; - const key = head.slice(0, commaIdx).trim(); - const durPart = head.slice(commaIdx + 1).trim(); - return { key, durPart, bodyPart }; -} - -function reconstructTokensForEdit(s, field, newValue, oldSourceToken) { - const val = (newValue || '').trim().toLowerCase(); - if (!val) return null; - - if (s.type === 'press') { - // Only 'key' editable - return [val]; - } - - if (s.type === 'press_wait') { - // Two source tokens: key token + wait:Xs token - const key = field === 'key' ? val : (s.input || '').toLowerCase(); - if (!key) return null; - const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; - if (!durMs) return null; - return [key, `wait:${formatDurationToken(durMs)}`]; - } - - if (s.type === 'hold') { - // One token: hold(key, durMs) - const key = field === 'key' ? val : (s.input || '').toLowerCase(); - if (!key) return null; - const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; - if (!durMs) return null; - return [`hold(${key}, ${formatDurationToken(durMs)})`]; - } - - if (s.type === 'spam') { - const key = field === 'key' ? val : (s.input || '').toLowerCase(); - if (!key) return null; - const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; - if (!durMs) return null; - return [`spam(${key}, ${formatDurationToken(durMs)})`]; - } - - if (s.type === 'hold_with_body') { - const parts = extractHoldWithBodyParts(oldSourceToken); - if (!parts) return null; - let key = parts.key.toLowerCase(); - let durMs = field === 'duration' ? parseDurationToMs(val) : Number(s.duration || 0); - if (field === 'key') key = val; - if (field === 'duration') { - if (!durMs || !Number.isFinite(durMs)) return null; - } else if (!durMs || !Number.isFinite(durMs)) { - durMs = parseDurationToMs(parts.durPart); - if (!durMs) return null; - } - const durTok = formatDurationToken(durMs); - return [`hold(${key}, ${durTok}, ${parts.bodyPart})`]; - } - - if (s.type === 'wait' && s.mode === 'mandatory') { - // One token: wait(key, durMs) - const key = field === 'key' ? val : (s.wait_for || '').toLowerCase(); - if (!key) return null; - const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; - if (!durMs) return null; - return [`wait(${key}, ${formatDurationToken(durMs)})`]; - } - - if (s.type === 'wait') { - // Standalone soft/hard wait: one token wait:Xs - const durMs = parseDurationToMs(val); - if (!durMs) return null; - return [`wait:${formatDurationToken(durMs)}`]; - } - - return null; -} - -/** Snapshot of the fields save_combo persists, used to restore prior state on undo. */ -function captureComboSnapshot() { - return { - name: (getEl('comboName')?.value || '').toString(), - inputs: (getEl('comboInputs')?.value || '').toString(), - enders: (getEl('comboEnders')?.value || '').toString(), - expected_time: (getEl('comboExpectedTime')?.value || '').toString(), - user_difficulty: (getEl('comboUserDifficulty')?.value || '').toString(), - step_display_mode: getEl('stepDisplayToggle')?.checked ? 'images' : 'icons', - key_images: { ...appState.keyImages }, - demo_video: (getEl('comboDemoVideo')?.value || '').toString().trim(), - target_game: appState.targetGame, - ww_team_id: appState.wwTeamId || '', - }; -} - -const EDIT_STEPS_UNDO_LIMIT = 50; - -/** Call before applying an Edit Steps mutation (delete/reorder/inline edit) so it can be undone. */ -function pushEditStepsUndoSnapshot() { - appState.editStepsUndoStack.push(captureComboSnapshot()); - if (appState.editStepsUndoStack.length > EDIT_STEPS_UNDO_LIMIT) appState.editStepsUndoStack.shift(); -} - -/** Ctrl/Cmd+Z while Edit Steps is on: restore the state captured before the last mutation. */ -function undoLastEditStep() { - const snapshot = appState.editStepsUndoStack.pop(); - if (!snapshot) return false; - sendMessage('save_combo', snapshot); - updateStatus('Undid last step edit.', 'neutral'); - return true; -} - -/** - * Commit an inline step field edit: - * 1. Locate source token(s) via runtime_source_token_indices (runtime index → source indices). - * 2. Splice new token(s) into the inputs textarea. - * 3. Send save_combo with the updated inputs string. - */ -function commitStepFieldEdit(runtimeIdx, s, field, newValue) { - const inputsEl = getEl('comboInputs'); - if (!inputsEl) return false; - - const raw = inputsEl.value || ''; - const currentTokens = splitInputsTokens(raw); - if (!currentTokens.length) return false; - - const srcMap = buildRuntimeToSourceMap(currentTokens); - if (!srcMap || runtimeIdx >= srcMap.length) return false; - - const srcIndices = srcMap[runtimeIdx]; - if (!srcIndices || srcIndices.length === 0) return false; - - const minSrc = Math.min(...srcIndices); - const oldSourceToken = currentTokens[minSrc]; - const newTokens = reconstructTokensForEdit(s, field, newValue, oldSourceToken); - if (!newTokens) return false; - - // Splice: replace source token(s) at srcIndices with newTokens. - const result = []; - let i = 0; - while (i < currentTokens.length) { - if (srcIndices.includes(i)) { - if (i === minSrc) { - newTokens.forEach(t => result.push(t)); - } - // skip the rest of the source group - } else { - result.push(currentTokens[i]); - } - i++; - } - if (result.length === 0) return false; - - const newInputs = result.join(', '); - pushEditStepsUndoSnapshot(); - inputsEl.value = newInputs; - if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); - - // Save via the same path as the Save/Update button. - readKeyImagesFromUI(); - const toggle = getEl('stepDisplayToggle'); - sendMessage('save_combo', { - name: (getEl('comboName')?.value || '').toString(), - inputs: newInputs, - enders: (getEl('comboEnders')?.value || '').toString(), - expected_time: (getEl('comboExpectedTime')?.value || '').toString(), - user_difficulty: (getEl('comboUserDifficulty')?.value || '').toString(), - step_display_mode: toggle?.checked ? 'images' : 'icons', - key_images: appState.keyImages, - demo_video: (getEl('comboDemoVideo')?.value || '').toString().trim(), - target_game: appState.targetGame, - ww_team_id: appState.wwTeamId || '', - }); - return true; -} - -/** - * JS port of runtime_source_token_indices_from_tokens from parser.py. - * Returns an array where entry[runtimeIdx] = [srcTokenIdx, ...]. - */ -function buildRuntimeToSourceMap(tokens) { - const srcMap = []; - let i = 0; - while (i < tokens.length) { - const tok = tokens[i].trim().toLowerCase(); - if (!tok) { i++; continue; } - - // press + following soft/hard wait -> one runtime SequenceNode (press_wait tile) - if (!tok.startsWith('wait') && !tok.startsWith('hold(') && !tok.startsWith('spam(') && !tok.startsWith('[') && !tok.startsWith('{')) { - // Could be a plain press followed by wait:Xs - if (i + 1 < tokens.length) { - const nxt = tokens[i + 1].trim().toLowerCase(); - if (nxt.startsWith('wait:')) { - srcMap.push([i, i + 1]); - i += 2; - continue; - } - } - } - - // wait(key, t) -> two runtime steps (press + mandatory wait) from the same source token - if (tok.startsWith('wait(') && tok.endsWith(')')) { - srcMap.push([i]); - srcMap.push([i]); - i++; - continue; - } - - // hold(key, dur, {body}) -> one runtime step (matches parser HoldWithBodyNode). - // hold(key, dur, total_ms) anim-lock -> two runtime steps from the same source token. - if (tok.startsWith('hold(') && tok.endsWith(')')) { - const raw = tokens[i].trim(); - const inner = raw.slice(5, -1); - if (inner.indexOf('{') !== -1) { - srcMap.push([i]); - i++; - continue; - } - const parts = inner.split(',').map(p => p.trim()); - if (parts.length >= 3) { - srcMap.push([i]); - srcMap.push([i]); - i++; - continue; - } - } - - srcMap.push([i]); - i++; - } - return srcMap; -} - -/** - * Make a span inline-editable on double-click when edit mode is active. - * `s` = step dict, `field` = 'key' | 'duration', `runtimeIdx` = first runtime index. - */ -function attachInlineEdit(span, s, field, runtimeIdx) { - if (!appState.stepEditMode) return; - span.classList.add('step-field-editable'); - span.title = 'Double-click to edit'; - - span.addEventListener('dblclick', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - if (span.querySelector('input')) return; // already editing - - const original = span.textContent; - // For duration fields strip the leading label text so user edits just the value - let editValue = original; - if (field === 'duration') { - // "hold 300ms" -> "300ms", "Wait 500ms" -> "500ms", "230ms" -> "230ms" - editValue = original.replace(/^(hold\s+|Wait\s+)/i, '').trim(); - editValue = editValue.replace(/\s*✓\s*$/u, '').trim(); - } - - const input = document.createElement('input'); - input.type = 'text'; - input.value = editValue; - input.className = 'step-field-input'; - input.size = Math.max(4, editValue.length + 1); - span.textContent = ''; - span.appendChild(input); - input.focus(); - input.select(); - - const commit = () => { - const newVal = input.value.trim(); - span.textContent = original; - span.classList.remove('step-field-editing'); - if (newVal && newVal !== editValue) { - const ok = commitStepFieldEdit(runtimeIdx, s, field, newVal); - if (!ok) { - span.title = 'Invalid value — double-click to try again'; - } - } - }; - - const cancel = () => { - span.textContent = original; - span.classList.remove('step-field-editing'); - }; - - span.classList.add('step-field-editing'); - input.addEventListener('blur', commit); - input.addEventListener('keydown', (ke) => { - if (ke.key === 'Enter') { ke.preventDefault(); input.blur(); } - if (ke.key === 'Escape') { ke.preventDefault(); input.removeEventListener('blur', commit); cancel(); } - }); - }); -} - -// Timeline rendering -function refreshTimelineIfLoaded() { - if (appState.lastTimelineSteps) updateTimeline(appState.lastTimelineSteps); -} - -function updateTimeline(steps, opts) { - opts = opts || {}; - const scrollOpts = { focusLatest: !!opts.focusLatest }; - appState.lastTimelineSteps = steps; - const container = getEl('comboTimeline'); - if (!container) return; - container.innerHTML = ''; - - const ctx = { - failByStep: appState.lastFailByStep, - stepDisplayMode: appState.stepDisplayMode, - keyImages: appState.keyImages, - targetGame: appState.targetGame, - wwSwapImages: appState.wwSwapImages, - wwDashImage: appState.wwDashImage, - wwLmbImages: appState.wwLmbImages, - wwAbilityImages: appState.wwAbilityImages, - showFailCount: appState.showFailCount, - }; - - // Names each step's move ("Zani Basic 2"); steps must be labeled in timeline order. - const labelMove = appState.showMoveNames ? createWwMoveLabeler(wwSlotNames()) : null; - function appendMoveLabel(tile, step, slot) { - if (!labelMove) return; - const text = labelMove(step, slot); - if (!text) return; - const el = document.createElement('span'); - el.className = 'step-move'; - el.textContent = text; - el.title = text; - tile.appendChild(el); - } - - const viewport = getEl('comboTimelineViewport'); - const isAutoScroll = viewport?.classList.contains('auto-scroll-on'); - let baseStepWidthPx = 90; - if (isAutoScroll) { - const probe = document.createElement('div'); - probe.style.cssText = 'position:absolute;visibility:hidden;min-width:var(--auto-scroll-step-min-width)'; - document.body.appendChild(probe); - const computedPx = getComputedStyle(probe).minWidth; - document.body.removeChild(probe); - const parsed = parseFloat(computedPx, 10); - if (Number.isFinite(parsed) && parsed > 0) baseStepWidthPx = parsed; - } - const DURATION_WIDTH_DIVISOR = 350; - const applyHoldWidth = (el, durationMs) => { - const ms = Number(durationMs); - const mult = (Number.isFinite(ms) && ms > 0) ? (ms / DURATION_WIDTH_DIVISOR) : 1; - const w = Math.max(baseStepWidthPx, baseStepWidthPx * mult); - el.style.minWidth = `${baseStepWidthPx}px`; - el.style.width = `${w}px`; - }; - const applyWaitWidth = (el, durationMs) => { - const ms = Number(durationMs); - const mult = (Number.isFinite(ms) && ms > 0) ? (ms / DURATION_WIDTH_DIVISOR) : 1; - const w = Math.max(baseStepWidthPx, baseStepWidthPx * mult); - el.style.minWidth = `${baseStepWidthPx}px`; - el.style.width = `${w}px`; - }; - const applyBaseWidth = (el) => { - el.style.minWidth = `${baseStepWidthPx}px`; - el.style.width = `${baseStepWidthPx}px`; - }; - const addCornerKey = (el, key, s, runtimeIdx) => { - if (ctx.stepDisplayMode !== 'images') return; - const k = (key || '').toString().trim(); - if (!k) return; - const span = document.createElement('span'); - span.className = 'corner-key'; - span.textContent = k.toUpperCase(); - el.appendChild(span); - if (s && runtimeIdx != null) attachInlineEdit(span, s, 'key', runtimeIdx); - }; - const parseStepIndices = (stepIndices) => { - if (!Array.isArray(stepIndices)) return []; - return stepIndices - .map(v => Number.parseInt(v, 10)) - .filter(v => Number.isFinite(v) && v >= 0); - }; - const attachStepDeleteControl = (el, stepIndices) => { - const indices = parseStepIndices(stepIndices); - if (!appState.stepEditMode || indices.length === 0) return; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'step-delete-btn'; - btn.title = 'Delete this step'; - btn.setAttribute('aria-label', 'Delete this step'); - btn.textContent = '🗑'; - btn.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - pushEditStepsUndoSnapshot(); - sendMessage('delete_timeline_step', { step_indices: indices }); - }); - el.appendChild(btn); - }; - - // Drag-to-reorder: the first runtime index stored in step_indices is used as the drag handle identifier. - const attachStepDragControl = (el, stepIndices) => { - const indices = parseStepIndices(stepIndices); - if (!appState.stepEditMode || indices.length === 0) return; - const fromRuntimeIdx = indices[0]; - el.setAttribute('draggable', 'true'); - el.dataset.runtimeIdx = String(fromRuntimeIdx); - - el.addEventListener('dragstart', (ev) => { - ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData('text/plain', String(fromRuntimeIdx)); - el.classList.add('step-dragging'); - }); - - el.addEventListener('dragend', () => { - el.classList.remove('step-dragging'); - container.querySelectorAll('.step-drag-over-before, .step-drag-over-after').forEach(t => { - t.classList.remove('step-drag-over-before', 'step-drag-over-after'); - }); - }); - - el.addEventListener('dragover', (ev) => { - ev.preventDefault(); - ev.dataTransfer.dropEffect = 'move'; - const draggingIdx = ev.dataTransfer.getData('text/plain'); - if (draggingIdx === String(fromRuntimeIdx)) return; - const rect = el.getBoundingClientRect(); - const midX = rect.left + rect.width / 2; - container.querySelectorAll('.step-drag-over-before, .step-drag-over-after').forEach(t => { - t.classList.remove('step-drag-over-before', 'step-drag-over-after'); - }); - if (ev.clientX < midX) { - el.classList.add('step-drag-over-before'); - } else { - el.classList.add('step-drag-over-after'); - } - }); - - el.addEventListener('dragleave', (ev) => { - if (!el.contains(ev.relatedTarget)) { - el.classList.remove('step-drag-over-before', 'step-drag-over-after'); - } - }); - - el.addEventListener('drop', (ev) => { - ev.preventDefault(); - const draggedRuntimeIdx = Number.parseInt(ev.dataTransfer.getData('text/plain'), 10); - el.classList.remove('step-drag-over-before', 'step-drag-over-after'); - if (!Number.isFinite(draggedRuntimeIdx) || draggedRuntimeIdx === fromRuntimeIdx) return; - pushEditStepsUndoSnapshot(); - const rect = el.getBoundingClientRect(); - const midX = rect.left + rect.width / 2; - if (ev.clientX < midX) { - // Drop before this tile - sendMessage('reorder_timeline_step', { - from_step_index: draggedRuntimeIdx, - before_step_index: fromRuntimeIdx, - }); - } else { - // Drop after this tile: find the next sibling's runtime index, or null to append. - const allTiles = Array.from(container.querySelectorAll('[data-runtime-idx]')); - const selfIdx = allTiles.indexOf(el); - const nextTile = allTiles[selfIdx + 1] || null; - const beforeIdx = nextTile ? Number.parseInt(nextTile.dataset.runtimeIdx, 10) : null; - sendMessage('reorder_timeline_step', { - from_step_index: draggedRuntimeIdx, - before_step_index: Number.isFinite(beforeIdx) ? beforeIdx : null, - }); - } - }); - }; - - function createGroupItemTile(it, characterId) { - const el = document.createElement('div'); - el.className = 'step group-item'; - - if (it.type === 'wait') { - el.classList.add('wait'); - if (it.duration <= SHORT_WAIT_MS) el.classList.add('short-wait'); - const pct = (it.progress !== undefined && it.progress !== null) ? it.progress : (it.completed ? 100 : 0); - el.style.setProperty('--wait-pct', `${pct}%`); - applyWaitWidth(el, it.duration); - } else if (it.type === 'press_wait' || it.type === 'spam') { - el.classList.add('press-wait'); - if (it.type === 'spam') el.classList.add('spam'); - if (it.duration <= SHORT_WAIT_MS) el.classList.add('short-wait'); - const pct = (it.progress !== undefined && it.progress !== null) ? it.progress : (it.completed ? 100 : 0); - el.style.setProperty('--wait-pct', `${pct}%`); - applyWaitWidth(el, it.duration); - } else if (it.type === 'hold') { - el.classList.add('hold'); - applyHoldWidth(el, it.duration); - el.style.setProperty('--hold-pct', it.completed ? '100%' : '0%'); - } else if (isAutoScroll) { - applyBaseWidth(el); - } - if (it.optional) el.classList.add('optional'); - if (it.optional && it.completed && !it.was_skipped) el.classList.add('was-pressed'); - - if (it.active) el.classList.add('active'); - if (it.completed) el.classList.add('completed'); - - appendStepContent(el, it, characterId, ctx); - appendMoveLabel(el, it, characterId); - - let keyForCorner = ''; - if (it.type === 'wait' && it.wait_for) keyForCorner = it.wait_for; - else if (it.input) keyForCorner = it.input; - if (keyForCorner) addCornerKey(el, keyForCorner); - - return el; - } - - function renderGroupStep(s, idx, activeChar) { - const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; - const failCount = indices.reduce((n, i) => n + (ctx.failByStep[String(i)] || ctx.failByStep[i] || 0), 0); - const showFailForStep = ctx.showFailCount && failCount > 0; - - const tile = document.createElement('div'); - tile.className = 'step-group'; - if (s.active) tile.classList.add('active'); - if (s.completed) tile.classList.add('completed'); - if (s.mark) { - const m = String(s.mark).toLowerCase(); - if (m === 'ok') tile.classList.add('mark-ok'); - else if (m === 'early') tile.classList.add('mark-early'); - else if (m === 'missed') tile.classList.add('mark-missed'); - else if (m === 'wrong') tile.classList.add('mark-wrong'); - } - if (showFailForStep) { - tile.classList.add('mark-missed'); - const badge = document.createElement('span'); - badge.className = 'step-fail-count'; - badge.textContent = String(failCount); - tile.appendChild(badge); - } - - const items = document.createElement('div'); - items.className = 'step-group-items'; - let nextChar = activeChar; - - (s.items || []).forEach(it => { - const itInp = (it.input || '').toString().toLowerCase(); - const itWait = (it.wait_for || '').toString().toLowerCase(); - - if (it.type === 'sequence') { - const seqEl = document.createElement('div'); - seqEl.className = 'step group-item group-item-sequence'; - if (it.active) seqEl.classList.add('active'); - if (it.completed) seqEl.classList.add('completed'); - - const seqItems = document.createElement('div'); - seqItems.className = 'mini-sequence-items'; - (it.items || []).forEach(seqIt => { - const seqItInp = (seqIt.input || '').toString().toLowerCase(); - const seqItWait = (seqIt.wait_for || '').toString().toLowerCase(); - if (['1', '2', '3'].includes(seqItInp)) nextChar = seqItInp; - else if (['1', '2', '3'].includes(seqItWait)) nextChar = seqItWait; - seqItems.appendChild(createGroupItemTile(seqIt, nextChar)); - }); - seqEl.appendChild(seqItems); - items.appendChild(seqEl); - } else { - if (['1', '2', '3'].includes(itInp)) nextChar = itInp; - else if (['1', '2', '3'].includes(itWait)) nextChar = itWait; - items.appendChild(createGroupItemTile(it, nextChar)); - } - }); - tile.appendChild(items); - attachStepDeleteControl(tile, s.step_indices); - attachStepDragControl(tile, s.step_indices); - return { tile, nextActiveChar: nextChar }; - } - - function renderSequenceStep(s, idx, activeChar) { - const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; - const failCount = indices.reduce((n, i) => n + (ctx.failByStep[String(i)] || ctx.failByStep[i] || 0), 0); - const showFailForStep = ctx.showFailCount && failCount > 0; - - const tile = document.createElement('div'); - tile.className = 'step-sequence'; - if (s.active) tile.classList.add('active'); - if (s.completed) tile.classList.add('completed'); - if (showFailForStep) { - tile.classList.add('mark-missed'); - const badge = document.createElement('span'); - badge.className = 'step-fail-count'; - badge.textContent = String(failCount); - tile.appendChild(badge); - } - - const items = document.createElement('div'); - items.className = 'sequence-items'; - let nextChar = activeChar; - - (s.items || []).forEach(it => { - const itInp = (it.input || '').toString().toLowerCase(); - const itWait = (it.wait_for || '').toString().toLowerCase(); - if (['1', '2', '3'].includes(itInp)) nextChar = itInp; - else if (['1', '2', '3'].includes(itWait)) nextChar = itWait; - - const itEl = document.createElement('div'); - itEl.className = 'step sequence-item'; - if (it.optional) itEl.classList.add('optional'); - if (it.optional && it.completed && !it.was_skipped) itEl.classList.add('was-pressed'); - if (it.active) itEl.classList.add('active'); - if (it.completed) itEl.classList.add('completed'); - appendStepContent(itEl, it, nextChar, ctx); - appendMoveLabel(itEl, it, nextChar); - items.appendChild(itEl); - }); - tile.appendChild(items); - attachStepDeleteControl(tile, s.step_indices); - attachStepDragControl(tile, s.step_indices); - return { tile, nextActiveChar: nextChar }; - } - - function renderNormalStep(s, idx, activeChar) { - const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; - const failCount = indices.reduce((n, i) => n + (ctx.failByStep[String(i)] || ctx.failByStep[i] || 0), 0); - const showFailForStep = ctx.showFailCount && failCount > 0; - - const sInp = (s.input || '').toString().toLowerCase(); - const sWait = (s.wait_for || '').toString().toLowerCase(); - let nextChar = activeChar; - if (['1', '2', '3'].includes(sInp)) nextChar = sInp; - else if (['1', '2', '3'].includes(sWait)) nextChar = sWait; - - const tile = document.createElement('div'); - tile.className = 'step'; - if (s.active) tile.classList.add('active'); - if (s.completed) tile.classList.add('completed'); - if (s.mark === 'success') tile.classList.add('mark-ok'); - if (s.mark === 'fail' || s.mark === 'wrong') tile.classList.add('mark-wrong'); - if (s.mark === 'missed' || showFailForStep) tile.classList.add('mark-missed'); - if (s.mark === 'early') tile.classList.add('mark-early'); - if (showFailForStep) { - const badge = document.createElement('span'); - badge.className = 'step-fail-count'; - badge.textContent = String(failCount); - tile.appendChild(badge); - } - - if (s.type) tile.classList.add(s.type.replace(/_/g, '-')); - if (s.optional) tile.classList.add('optional'); - if (s.optional && s.completed && !s.was_skipped) tile.classList.add('was-pressed'); - let pct = (s.progress !== undefined) ? s.progress : (s.completed ? 100 : 0); - if (s.type === 'wait' || s.type === 'press_wait' || s.type === 'spam') { - tile.style.setProperty('--wait-pct', `${pct}%`); - if (s.duration <= SHORT_WAIT_MS) tile.classList.add('short-wait'); - if (s.duration) applyWaitWidth(tile, s.duration); - } else if (s.type === 'hold' || s.type === 'hold_with_body') { - tile.style.setProperty('--hold-pct', `${pct}%`); - if (s.duration) applyHoldWidth(tile, s.duration); - } else if (isAutoScroll) { - applyBaseWidth(tile); - } - - const runtimeIdxNormal = Array.isArray(s.step_indices) ? s.step_indices[0] : idx; - let keyForCorner = ''; - if (s.type === 'wait' && s.wait_for) keyForCorner = s.wait_for; - else if (s.input) keyForCorner = s.input; - if (keyForCorner && s.type !== 'hold_with_body') addCornerKey(tile, keyForCorner, s, runtimeIdxNormal); - - appendStepContent(tile, s, nextChar, ctx, runtimeIdxNormal); - appendMoveLabel(tile, s, nextChar); - attachStepDeleteControl(tile, s.step_indices); - attachStepDragControl(tile, s.step_indices); - return { tile, nextActiveChar: nextChar }; - } - - function renderStep(s, idx, activeChar) { - const out = (s.type === 'group') - ? renderGroupStep(s, idx, activeChar) - : (s.type === 'sequence') - ? renderSequenceStep(s, idx, activeChar) - : renderNormalStep(s, idx, activeChar); - const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; - out.tile.dataset.stepIndices = indices.join(','); - return out; - } - - const buildCollapsedLmbPressWaitStep = (chainSteps) => { - const flattenedIndices = chainSteps.flatMap((it) => - Array.isArray(it.step_indices) ? it.step_indices : [] - ); - const totalDuration = chainSteps.reduce((sum, it) => sum + (Number(it.duration) || 0), 0); - const progressTotal = chainSteps.reduce((sum, it) => { - const pct = (it.progress !== undefined && it.progress !== null) ? Number(it.progress) : (it.completed ? 100 : 0); - return sum + (Number.isFinite(pct) ? pct : 0); - }, 0); - const avgProgress = chainSteps.length > 0 ? (progressTotal / chainSteps.length) : 0; - const anyMark = (name) => chainSteps.some((it) => (it.mark || '') === name); - let collapsedMark = ''; - if (anyMark('wrong') || anyMark('fail')) collapsedMark = 'wrong'; - else if (anyMark('missed')) collapsedMark = 'missed'; - else if (anyMark('early')) collapsedMark = 'early'; - else if (anyMark('success')) collapsedMark = 'success'; - - return { - ...chainSteps[0], - type: 'press_wait', - input: 'lmb', - duration: totalDuration, - progress: avgProgress, - chain_count: chainSteps.length, - chain_collapsed: true, - active: chainSteps.some((it) => !!it.active), - completed: chainSteps.every((it) => !!it.completed), - step_indices: flattenedIndices, - mark: collapsedMark, - }; - }; - - if (!steps || steps.length === 0) { - container.innerHTML = '
No combo selected
'; - return; - } - - const isLmbPressWait = (step) => - !!step - && step.type === 'press_wait' - && ((step.input || '').toString().toLowerCase() === 'lmb'); - - const collapseChains = !!appState.collapseChainedPresses; - let activeChar = '1'; - for (let idx = 0; idx < steps.length; idx += 1) { - const s = steps[idx]; - - if (collapseChains && isLmbPressWait(s)) { - let chainEnd = idx + 1; - while (chainEnd < steps.length && isLmbPressWait(steps[chainEnd])) chainEnd += 1; - const chainLen = chainEnd - idx; - if (chainLen > 1) { - const collapsed = buildCollapsedLmbPressWaitStep(steps.slice(idx, chainEnd)); - const { tile, nextActiveChar } = renderStep(collapsed, idx, activeChar); - tile.classList.add('chain-collapsed'); - activeChar = nextActiveChar; - container.appendChild(tile); - idx = chainEnd - 1; - continue; - } - } - - const { tile, nextActiveChar } = renderStep(s, idx, activeChar); - activeChar = nextActiveChar; - container.appendChild(tile); - if (!collapseChains && isLmbPressWait(s) && isLmbPressWait(steps[idx + 1])) { - const dash = document.createElement('div'); - dash.className = 'timeline-chain-dash'; - dash.setAttribute('aria-hidden', 'true'); - container.appendChild(dash); - } - } - - renderAvgSplitsOnTimeline(); - - if (viewport?.classList.contains('auto-scroll-on')) { - requestAnimationFrame(() => { - normalizeStepHeightsInAutoScroll(container); - applyAutoScroll(scrollOpts); - }); - } else if (appState.autoScrollEnabled) { - requestAnimationFrame(() => applyAutoScroll(scrollOpts)); - } -} - -function normalizeStepHeightsInAutoScroll(timelineEl) { - if (!timelineEl) return; - const stepTiles = timelineEl.querySelectorAll(':scope > .step, :scope .step.group-item'); - if (stepTiles.length === 0) return; - stepTiles.forEach(el => { el.style.minHeight = ''; }); - const heights = Array.from(stepTiles).map(el => el.getBoundingClientRect().height); - const maxH = Math.max(...heights, 0); - if (maxH > 0) stepTiles.forEach(el => { el.style.minHeight = `${maxH}px`; }); -} - -function renderStepLabel(s) { - if (s.type === 'sequence') { - const parts = (s.items || []).map(it => renderStepLabel(it)); - return parts.length > 0 ? `Seq: ${parts.join('→')}` : 'Seq'; - } - const inp = (s.input || '').toString().toUpperCase(); - const dur = s.duration || 0; - if (s.type === 'wait') { - if (s.mode === 'mandatory' && s.wait_for) { - return `${s.wait_for.toUpperCase()} (${dur}ms)`; - } - return `Wait ${dur}ms`; - } - if (s.type === 'hold') return `Hold ${inp} ${dur}ms`; - if (s.type === 'hold_with_body') return `Hold ${inp} ${dur}ms (+body)`; - if (s.type === 'spam') return `Spam ${inp} ${dur}ms`; - if (s.type === 'press_wait') return `${inp} + ${dur}ms`; - return inp; -} - -function getMouseIconSvg(type) { - const t = type.toLowerCase(); - if (t === 'lmb') { - return ``; - } - if (t === 'rmb') { - return ``; - } - if (t === 'mmb') { - return ``; - } - return ''; -} - -function appendStepContent(parent, s, characterId, ctx, runtimeIdx) { - const useImages = (ctx && ctx.stepDisplayMode === 'images') || !!getEl('stepDisplayToggle')?.checked; - const inp = (s.input || '').toString().toLowerCase(); - const label = (s.input || '').toString().toUpperCase(); - const charId = characterId || '1'; - - // Helper to decide content (icon or text when no image) - const appendIconOrText = (key, fallbackText, target = parent) => { - const svg = getMouseIconSvg(key); - if (svg) { - const icon = document.createElement('span'); - icon.className = 'mouse-icon'; - icon.innerHTML = svg; - target.appendChild(icon); - return; - } - const span = document.createElement('span'); - span.className = 'step-primary'; - if (key === 'space') { - span.textContent = '⎵'; - span.classList.add('space-icon'); - } else { - span.textContent = fallbackText; - } - target.appendChild(span); - }; - - // Resolve image URL based on current game/mode (WW vs generic); returns null when no image. - const resolveImage = (key) => { - if (!useImages) return null; - if (ctx && ctx.targetGame === 'wuthering_waves') { - return getWwImage(key, charId, ctx); - } - return (ctx && ctx.keyImages[key]) || null; - }; - - // Append primary content: image if available, else icon/text. - const appendPrimary = (key, fallbackText, target = parent) => { - const imgUrl = resolveImage(key); - if (imgUrl) { - target.appendChild(createImageElement(imgUrl)); - } else { - appendIconOrText(key, fallbackText, target); - } - }; - - // Append duration/secondary text; attach inline edit when runtimeIdx is provided. - const appendDuration = (text, target = parent) => { - const dur = document.createElement('span'); - dur.className = 'step-secondary'; - dur.textContent = text; - target.appendChild(dur); - if (runtimeIdx != null) attachInlineEdit(dur, s, 'duration', runtimeIdx); - }; - - // Single unified logic — no duplication across WW / generic / icons. - if (s.type === 'wait' && s.mode === 'mandatory' && s.wait_for) { - const key = s.wait_for.toLowerCase(); - appendPrimary(key, key.toUpperCase()); - appendDuration(`${s.duration}ms`); - } else if (s.type === 'hold_with_body') { - const shell = document.createElement('div'); - shell.className = 'hold-body-layout'; - - const anchor = document.createElement('div'); - anchor.className = 'hold-anchor'; - appendPrimary(inp, label, anchor); - - const keyTag = document.createElement('span'); - keyTag.className = 'corner-key hold-anchor-key'; - keyTag.textContent = label; - anchor.appendChild(keyTag); - if (runtimeIdx != null) attachInlineEdit(keyTag, s, 'key', runtimeIdx); - - const right = document.createElement('div'); - right.className = 'hold-content'; - const bodyDoneLabel = s.body_done ? ' ✓' : ''; - appendDuration(`hold ${s.duration}ms${bodyDoneLabel}`, right); - - const bodyRow = document.createElement('div'); - bodyRow.className = 'hold-body-row'; - const bodyItems = (s.body && Array.isArray(s.body.items)) ? s.body.items : []; - bodyItems.forEach((it) => { - const chip = document.createElement('span'); - const isActive = !!(it && it.active); - const isCompleted = !!(it && it.completed); - const itemType = (it && it.type ? String(it.type) : '').toLowerCase(); - if (itemType === 'wait') { - chip.className = 'hold-body-chip wait'; - chip.textContent = `${Number(it.duration || 0)}ms`; - const waitPct = Number(it.progress); - if (Number.isFinite(waitPct)) { - chip.style.setProperty('--chip-wait-pct', `${Math.max(0, Math.min(100, waitPct))}%`); - } - } else { - chip.className = 'hold-body-chip press'; - const key = (it && it.input ? it.input : '').toString().toLowerCase(); - if (key) { - const imgUrl = resolveImage(key); - if (imgUrl) chip.appendChild(createImageElement(imgUrl)); - else appendIconOrText(key, key.toUpperCase(), chip); - } else { - chip.textContent = '?'; - } - // Body sequences often arrive as press_wait (collapsed press + following wait). - // Render the wait timing inline so all hold-body waits stay visible. - if (itemType === 'press_wait' || itemType === 'spam') { - chip.classList.add('press-wait'); - if (itemType === 'spam') chip.classList.add('spam'); - const inlineWait = document.createElement('span'); - inlineWait.className = 'hold-body-inline-wait'; - inlineWait.textContent = itemType === 'spam' - ? `spam ${Number(it.duration || 0)}ms` - : `${Number(it.duration || 0)}ms`; - const waitPct = Number(it.progress); - if (Number.isFinite(waitPct)) { - inlineWait.style.setProperty('--chip-wait-pct', `${Math.max(0, Math.min(100, waitPct))}%`); - } - chip.appendChild(inlineWait); - } - } - if (isActive) chip.classList.add('active'); - if (isCompleted) chip.classList.add('completed'); - bodyRow.appendChild(chip); - }); - right.appendChild(bodyRow); - - shell.appendChild(anchor); - shell.appendChild(right); - parent.appendChild(shell); - } else if (s.type === 'hold') { - appendPrimary(inp, label); - appendDuration(`hold ${s.duration}ms`); - } else if (s.type === 'spam') { - appendPrimary(inp, label); - appendDuration(`spam ${s.duration}ms`); - } else if (s.type === 'press_wait') { - const chainCount = Number(s.chain_count || 0); - if (chainCount > 1) { - const primaryRow = document.createElement('div'); - primaryRow.className = 'step-chain-primary'; - appendPrimary(inp, label, primaryRow); - const count = document.createElement('span'); - count.className = 'step-chain-count'; - count.textContent = `x${chainCount}`; - primaryRow.appendChild(count); - parent.appendChild(primaryRow); - } else { - appendPrimary(inp, label); - } - appendDuration(`${s.duration}ms`); - } else if (s.type === 'wait') { - appendDuration(`Wait ${s.duration}ms`); - } else { - appendPrimary(inp, label); - } -} - -function getWwImage(key, characterId, ctx) { - if (!ctx) return null; - const k = key.toLowerCase(); - const cid = characterId || '1'; - - // Check if it's a swap key (1/2/3) - Return the swap icon for that character regardless of who is active - if (['1', '2', '3'].includes(k)) { - return ctx.wwSwapImages[k] || null; - } - // Check if it's RMB (dash) - shared dash image - if (k === 'rmb') { - return ctx.wwDashImage || null; - } - - // Check if it's LMB - use active character - if (k === 'lmb') { - return ctx.wwLmbImages[cid] || null; - } - - // Check if it's an ability (e/q/r) - use active character - if (['e', 'q', 'r'].includes(k)) { - if (ctx.wwAbilityImages[cid] && ctx.wwAbilityImages[cid][k]) { - return ctx.wwAbilityImages[cid][k]; - } - // Fallback: search all characters if not found for specific one (legacy behavior, optional) - for (const c of ['1', '2', '3']) { - if (ctx.wwAbilityImages[c] && ctx.wwAbilityImages[c][k]) { - return ctx.wwAbilityImages[c][k]; - } - } - } - return null; -} - -function createImageElement(url) { - const img = document.createElement('span'); - img.className = 'key-img-wrap'; // Matches CSS .key-img-wrap - if (/^https?:\/\//i.test(url)) { - img.innerHTML = ``; - } else { - img.innerHTML = `${escapeHtml(url)}`; - } - return img; -} - -function setAutoScrollEnabled(enabled) { - appState.autoScrollEnabled = !!enabled; - const vp = getEl('comboTimelineViewport'); - const timeline = getEl('comboTimeline'); - if (vp) { - if (appState.autoScrollEnabled) { - vp.classList.add('auto-scroll-on'); - refreshTimelineIfLoaded(); - } else { - vp.classList.remove('auto-scroll-on'); - if (timeline) timeline.style.transform = 'none'; - refreshTimelineIfLoaded(); - } - } -} - -/** - * Element to center when auto-scroll is on. - * Prefers the last top-level tile when `focusLatest` is set (server: transcription updates) or when - * transcribe checkbox is on (main UI). Otherwise uses `.step.active` (combo practice / playback). - */ -function autoScrollTimelineTargetEl(timeline, scrollOpts) { - scrollOpts = scrollOpts || {}; - if (!timeline) return null; - const preferLatest = - !!scrollOpts.focusLatest - || !!getEl('transcribeModeToggle')?.checked; - if (preferLatest) { - const tops = [...timeline.children].filter( - (c) => - c.classList.contains('step-group') - || c.classList.contains('step-sequence') - || (c.classList.contains('step') - && !c.classList.contains('group-item') - && !c.classList.contains('sequence-item')), - ); - if (tops.length) return tops[tops.length - 1]; - } - return timeline.querySelector('.step.active'); -} - -function applyAutoScroll(scrollOpts) { - if (!appState.autoScrollEnabled) return; - const viewport = getEl('comboTimelineViewport'); - const timeline = getEl('comboTimeline'); - if (!viewport || !timeline) return; - - const target = autoScrollTimelineTargetEl(timeline, scrollOpts); - if (!target) return; - - const vpRect = viewport.getBoundingClientRect(); - const activeRect = target.getBoundingClientRect(); - - const vpCenter = vpRect.left + (vpRect.width / 2); - const activeCenter = activeRect.left + (activeRect.width / 2); - const offset = activeCenter - vpCenter; - - // We must use transform for positioning as per layout - const style = window.getComputedStyle(timeline); - let currentX = 0; - if (style.transform && style.transform !== 'none') { - try { - // Logic to parse matrix(1, 0, 0, 1, x, y) - const matrix = new DOMMatrix(style.transform); - currentX = matrix.m41; - } catch (e) { - console.error('AutoScroll matrix parse error', e); - } - } - - // Shift to left to compensate positive offset (right-side target) - const newX = currentX - offset; - timeline.style.transform = `translateX(${newX}px)`; -} - -// Two-click confirm pattern -function attachTwoClickConfirm(btn, opts) { - let armed = false; - let timer = null; - const origText = btn.textContent; - btn.addEventListener('click', () => { - if (armed) { - armed = false; - clearTimeout(timer); - btn.textContent = origText; - if (opts.onConfirm) opts.onConfirm(); - } else { - armed = true; - btn.textContent = opts.confirmText || 'Confirm?'; - timer = setTimeout(() => { - armed = false; - btn.textContent = origText; - }, 3000); - } - }); -} - -// Combo selector -const comboSelector = getEl('comboSelector'); -if (comboSelector) { - comboSelector.addEventListener('change', () => { - const name = comboSelector.value; - if (name) { - sendMessage('select_combo', { name }); - } - }); -} - -const noFailModeEl = getEl('noFailMode'); -if (noFailModeEl) { - noFailModeEl.addEventListener('change', () => { - sendMessage('set_no_fail', { enabled: noFailModeEl.checked }); - }); -} - -// Save/Update button -const saveBtn = getEl('saveBtn'); -if (saveBtn) { - saveBtn.addEventListener('click', () => { - readKeyImagesFromUI(); - - const name = (getEl('comboName')?.value || '').toString(); - const inputs = (getEl('comboInputs')?.value || '').toString(); - const enders = (getEl('comboEnders')?.value || '').toString(); - const expectedTime = (getEl('comboExpectedTime')?.value || '').toString(); - const userDifficulty = (getEl('comboUserDifficulty')?.value || '').toString(); - const toggle = getEl('stepDisplayToggle'); - const mode = toggle?.checked ? 'images' : 'icons'; - - const demoVideo = (getEl('comboDemoVideo')?.value || '').toString().trim(); - sendMessage('save_combo', { - name, - inputs, - enders, - expected_time: expectedTime, - user_difficulty: userDifficulty, - step_display_mode: mode, - key_images: appState.keyImages, - demo_video: demoVideo, - target_game: appState.targetGame, - ww_team_id: appState.wwTeamId || '' - }); - }); -} - -// Ctrl/Cmd+S: save the current combo instead of triggering the browser's save-page dialog -document.addEventListener('keydown', (e) => { - if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 's') { - e.preventDefault(); - saveBtn?.click(); - } -}); - -// Ctrl/Cmd+Z: undo the last Edit Steps mutation (delete, reorder, or inline field edit) -document.addEventListener('keydown', (e) => { - if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { - if (!appState.stepEditMode || appState.editStepsUndoStack.length === 0) return; - e.preventDefault(); - undoLastEditStep(); - } -}); - -// Demo video input: update embed preview on change -const comboDemoVideoEl = getEl('comboDemoVideo'); -if (comboDemoVideoEl) { - comboDemoVideoEl.addEventListener('input', () => updateDemoVideoEmbed(comboDemoVideoEl.value)); - comboDemoVideoEl.addEventListener('change', () => updateDemoVideoEmbed(comboDemoVideoEl.value)); -} - -// New combo button -const newBtn = getEl('newBtn'); -if (newBtn) { - newBtn.addEventListener('click', () => { - sendMessage('new_combo'); - }); -} - -// Delete combo button -const deleteBtn = getEl('deleteBtn'); -if (deleteBtn) { - attachTwoClickConfirm(deleteBtn, { - confirmText: 'Confirm delete', - onConfirm: () => { - const name = (getEl('comboName')?.value || '').toString(); - if (name) { - sendMessage('delete_combo', { name }); - } - } - }); -} - -// Reload combos.json from disk (same folder as the app / exe) -const loadJsonBtn = getEl('loadJsonBtn'); -if (loadJsonBtn) { - loadJsonBtn.addEventListener('click', () => { - if (!window.confirm('Reload all combos and settings from combos.json? Unsaved changes in the editor will be lost.')) { - return; - } - sendMessage('load_combos_from_json', {}); - }); -} - -// Clear history button -const clearBtn = getEl('clearBtn'); -if (clearBtn) { - attachTwoClickConfirm(clearBtn, { - confirmText: 'Clear all', - onConfirm: () => { - sendMessage('clear_history'); - } - }); -} - -// Clear ALL history (every combo) button -const clearAllBtn = getEl('clearAllBtn'); -if (clearAllBtn) { - attachTwoClickConfirm(clearAllBtn, { - confirmText: 'Wipe every combo?', - onConfirm: () => { - sendMessage('clear_history_all'); - } - }); -} - -// Log attempts toggle: when off (default), suppress new entries in the attempt log. -const logAttemptsToggleEl = getEl('logAttemptsToggle'); -if (logAttemptsToggleEl) { - let initial = false; - try { - initial = localStorage.getItem(LOG_ATTEMPTS_STORAGE_KEY) === '1'; - } catch (_) { - initial = false; - } - logAttemptsToggleEl.checked = initial; - setLogAttemptsEnabled(initial); - logAttemptsToggleEl.addEventListener('change', () => { - setLogAttemptsEnabled(logAttemptsToggleEl.checked); - }); -} - -function scrollToBottom(el) { - const target = (typeof el === 'string') ? getEl(el) : el; - if (!target) return; - target.scrollTop = target.scrollHeight; -} - -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// Wire up editor UI events -const stepToggleEl = getEl('stepDisplayToggle'); -if (stepToggleEl) { - stepToggleEl.addEventListener('change', () => { - appState.stepDisplayMode = stepToggleEl.checked ? 'images' : 'icons'; - syncGameUIVisibility(); - refreshTimelineIfLoaded(); - }); -} - -const autoScrollToggleEl = getEl('autoScrollToggle'); -if (autoScrollToggleEl) { - if (document.body.classList.contains('timeline-window-view')) { - autoScrollToggleEl.checked = true; - setAutoScrollEnabled(true); - } else { - setAutoScrollEnabled(autoScrollToggleEl.checked); - } - autoScrollToggleEl.addEventListener('change', () => { - setAutoScrollEnabled(autoScrollToggleEl.checked); - refreshTimelineIfLoaded(); - }); -} - -const showFailCountEl = getEl('showFailCount'); -if (showFailCountEl) { - showFailCountEl.addEventListener('change', () => { - appState.showFailCount = showFailCountEl.checked; - refreshTimelineIfLoaded(); - }); -} - -const moveNamesToggleEl = getEl('moveNamesToggle'); -if (moveNamesToggleEl) { - moveNamesToggleEl.checked = !!appState.showMoveNames; - moveNamesToggleEl.addEventListener('change', () => { - appState.showMoveNames = moveNamesToggleEl.checked; - writeStoredFlag('showMoveNames', appState.showMoveNames); - refreshTimelineIfLoaded(); - }); -} - -const stepEditToggleEl = getEl('stepEditToggle'); -if (stepEditToggleEl) { - stepEditToggleEl.checked = !!appState.stepEditMode; - stepEditToggleEl.addEventListener('change', () => { - appState.stepEditMode = stepEditToggleEl.checked; - refreshTimelineIfLoaded(); - }); -} - -const collapseChainsToggleEl = getEl('collapseChainsToggle'); -if (collapseChainsToggleEl) { - collapseChainsToggleEl.checked = !!appState.collapseChainedPresses; - collapseChainsToggleEl.addEventListener('change', () => { - appState.collapseChainedPresses = collapseChainsToggleEl.checked; - refreshTimelineIfLoaded(); - }); -} - -const legendBtn = getEl('legendBtn'); -if (legendBtn) { - legendBtn.addEventListener('click', () => { - const panel = getEl('legendPanel'); - if (!panel) return; - const open = panel.classList.toggle('hidden') === false; - legendBtn.setAttribute('aria-expanded', String(open)); - }); -} - -const copyOverlayUrlBtn = getEl('copyOverlayUrlBtn'); -if (copyOverlayUrlBtn) { - copyOverlayUrlBtn.addEventListener('click', () => { - navigator.clipboard.writeText(getTimelineUrl()).then(() => { - const prevTitle = copyOverlayUrlBtn.title; - copyOverlayUrlBtn.title = 'Copied!'; - setTimeout(() => { copyOverlayUrlBtn.title = prevTitle; }, 1500); - }); - }); -} - -const openTimelineWindowBtn = getEl('openTimelineWindowBtn'); -if (openTimelineWindowBtn) { - openTimelineWindowBtn.addEventListener('click', () => { - window.open(getTimelineUrl(), 'combo-tracker-timeline', 'width=900,height=400,menubar=no,toolbar=no'); - }); -} - -function sendTranscribeMode() { - const toggle = getEl('transcribeModeToggle'); - const validInput = getEl('transcribeValidKeys'); - const startInput = getEl('transcribeStartKey'); - const stripToggle = getEl('transcribeStripWaitToggle'); - const stripMsEl = getEl('transcribeStripWaitMs'); - sendMessage('set_transcribe_mode', { - enabled: !!(toggle && toggle.checked), - valid_keys: (validInput && validInput.value.trim()) || '', - start_key: (startInput && startInput.value.trim()) || '', - strip_wait_under_enabled: !!(stripToggle && stripToggle.checked), - strip_wait_under_ms: (stripMsEl && stripMsEl.value.trim()) || '' - }); -} - -const transcribeModeToggle = getEl('transcribeModeToggle'); -const transcribeValidKeysWrap = getEl('transcribeValidKeysWrap'); -const transcribeValidKeysInput = getEl('transcribeValidKeys'); -const transcribeStartKeyInput = getEl('transcribeStartKey'); -const transcribeStripWaitToggle = getEl('transcribeStripWaitToggle'); -const transcribeStripWaitMs = getEl('transcribeStripWaitMs'); - -function transcribePersistIfOn() { - if (transcribeModeToggle && transcribeModeToggle.checked) sendTranscribeMode(); -} -if (transcribeModeToggle) { - transcribeModeToggle.addEventListener('change', () => { - if (transcribeModeToggle.checked && macroModeToggle && macroModeToggle.checked) { - macroModeToggle.checked = false; - if (macroSettingsWrap) macroSettingsWrap.classList.add('hidden'); - sendMacroMode(); - } - if (transcribeValidKeysWrap) transcribeValidKeysWrap.classList.toggle('hidden', !transcribeModeToggle.checked); - sendTranscribeMode(); - }); -} -if (transcribeValidKeysInput) { - transcribeValidKeysInput.addEventListener('blur', transcribePersistIfOn); - transcribeValidKeysInput.addEventListener('input', transcribePersistIfOn); -} -if (transcribeStartKeyInput) { - transcribeStartKeyInput.addEventListener('blur', transcribePersistIfOn); - transcribeStartKeyInput.addEventListener('input', transcribePersistIfOn); -} -if (transcribeStripWaitToggle) { - transcribeStripWaitToggle.addEventListener('change', transcribePersistIfOn); -} -if (transcribeStripWaitMs) { - transcribeStripWaitMs.addEventListener('blur', transcribePersistIfOn); - transcribeStripWaitMs.addEventListener('input', transcribePersistIfOn); -} -if (transcribeValidKeysWrap && transcribeModeToggle) { - transcribeValidKeysWrap.classList.toggle('hidden', !transcribeModeToggle.checked); -} - -// --- Macro replay mode --- -function sendMacroMode() { - const toggle = getEl('macroModeToggle'); - const startInput = getEl('macroStartKey'); - const stopInput = getEl('macroStopKey'); - const spamIntervalInput = getEl('macroSpamIntervalMs'); - sendMessage('set_macro_mode', { - enabled: !!(toggle && toggle.checked), - start_key: (startInput && startInput.value.trim()) || '', - stop_key: (stopInput && stopInput.value.trim()) || '', - spam_interval_ms: (spamIntervalInput && spamIntervalInput.value.trim()) || '', - }); -} - -function macroPersistIfOn() { - if (getEl('macroModeToggle')?.checked) sendMacroMode(); -} - -const macroModeToggle = getEl('macroModeToggle'); -const macroSettingsWrap = getEl('macroSettingsWrap'); -const macroStartKeyInput = getEl('macroStartKey'); -const macroStopKeyInput = getEl('macroStopKey'); -const macroSpamIntervalInput = getEl('macroSpamIntervalMs'); - -if (macroModeToggle) { - macroModeToggle.addEventListener('change', () => { - if (macroModeToggle.checked && transcribeModeToggle && transcribeModeToggle.checked) { - transcribeModeToggle.checked = false; - if (transcribeValidKeysWrap) transcribeValidKeysWrap.classList.add('hidden'); - sendTranscribeMode(); - } - if (macroSettingsWrap) macroSettingsWrap.classList.toggle('hidden', !macroModeToggle.checked); - sendMacroMode(); - }); -} -if (macroStartKeyInput) { - macroStartKeyInput.addEventListener('blur', macroPersistIfOn); - macroStartKeyInput.addEventListener('input', macroPersistIfOn); -} -if (macroStopKeyInput) { - macroStopKeyInput.addEventListener('blur', macroPersistIfOn); - macroStopKeyInput.addEventListener('input', macroPersistIfOn); -} -if (macroSpamIntervalInput) { - macroSpamIntervalInput.addEventListener('blur', macroPersistIfOn); - macroSpamIntervalInput.addEventListener('input', macroPersistIfOn); -} -if (macroSettingsWrap && macroModeToggle) { - macroSettingsWrap.classList.toggle('hidden', !macroModeToggle.checked); -} - -window.addEventListener('resize', () => { - if (appState.autoScrollEnabled) applyAutoScroll(); -}); - -/** - * Tokenize combo input string for syntax highlighting. Returns HTML with spans. - */ -function tokenizeComboInput(text) { - const escape = (s) => String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - const tokens = []; - let i = 0; - const len = text.length; - while (i < len) { - // wait(key, duration) - animation lock - if (text.slice(i).match(/^wait\s*\(/)) { - const start = i; - i += text.slice(i).match(/^wait\s*\(/)[0].length; - let depth = 1; - while (i < len && depth) { - if (text[i] === '(') depth++; - else if (text[i] === ')') depth--; - i++; - } - tokens.push({ type: 'anim-wait', text: text.slice(start, i) }); - continue; - } - // hold(key, duration) - if (text.slice(i).match(/^hold\s*\(/)) { - const start = i; - i += text.slice(i).match(/^hold\s*\(/)[0].length; - let depth = 1; - while (i < len && depth) { - if (text[i] === '(') depth++; - else if (text[i] === ')') depth--; - i++; - } - tokens.push({ type: 'hold', text: text.slice(start, i) }); - continue; - } - // wait:duration (soft wait) - if (text.slice(i).match(/^wait\s*:/)) { - const start = i; - i += text.slice(i).match(/^wait\s*:/)[0].length; - while (i < len && /[^\s,\[\]\{\}]/.test(text[i])) i++; - tokens.push({ type: 'wait', text: text.slice(start, i) }); - continue; - } - // optional -key - if (text[i] === '-' && i + 1 < len && /[a-zA-Z0-9]/.test(text[i + 1])) { - const start = i; - i++; - while (i < len && /[a-zA-Z0-9]/.test(text[i])) i++; - tokens.push({ type: 'optional', text: text.slice(start, i) }); - continue; - } - // brackets and braces - if (text[i] === '[' || text[i] === ']') { - tokens.push({ type: 'bracket', text: text[i] }); - i++; - continue; - } - if (text[i] === '{' || text[i] === '}') { - tokens.push({ type: 'sequence', text: text[i] }); - i++; - continue; - } - if (text[i] === ',') { - tokens.push({ type: 'punct', text: ',' }); - i++; - continue; - } - // default: one character (preserve newlines/spaces) - const ch = text[i]; - tokens.push({ type: 'default', text: ch }); - i++; - } - return tokens.map(t => { - const escaped = escape(t.text); - if (t.type === 'default') return escaped; - return `${escaped}`; - }).join(''); -} - -function updateComboInputHighlight() { - const ta = getEl('comboInputs'); - const mirror = getEl('comboInputHighlight'); - if (!ta || !mirror) return; - const raw = (ta.value || ''); - mirror.innerHTML = raw ? tokenizeComboInput(raw) : ''; - mirror.scrollTop = ta.scrollTop; - mirror.scrollLeft = ta.scrollLeft; -} - -const inputsEl = getEl('comboInputs'); -if (inputsEl) { - let t = null; - inputsEl.addEventListener('input', () => { - updateComboInputHighlight(); - if (t) clearTimeout(t); - t = setTimeout(() => { - renderKeyImagesEditor(); - }, 150); - }); - inputsEl.addEventListener('scroll', () => { - const mirror = getEl('comboInputHighlight'); - if (mirror) { - mirror.scrollTop = inputsEl.scrollTop; - mirror.scrollLeft = inputsEl.scrollLeft; - } - }); - // Initial highlight if textarea already has content (e.g. restored state) - updateComboInputHighlight(); -} - -const keyImagesDetails = getEl('keyImagesDetails'); -if (keyImagesDetails) { - keyImagesDetails.addEventListener('toggle', () => { - // Ensure UI is up-to-date when opening/closing - renderKeyImagesEditor(); - }); -} - -// WW: one-click character sync from wuthering.gg -const wwSyncCharNameEl = getEl('wwSyncCharName'); -function syncCharacterFromWeb() { - const name = (wwSyncCharNameEl?.value || '').toString().trim(); - if (!name) { updateStatus('Type a character name first.', 'fail'); return; } - sendMessage('sync_character', { name }); - wwSyncCharNameEl.value = ''; -} -getEl('wwSyncCharBtn')?.addEventListener('click', syncCharacterFromWeb); -wwSyncCharNameEl?.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); syncCharacterFromWeb(); } -}); -getEl('wwSyncAllBtn')?.addEventListener('click', () => sendMessage('sync_all_characters')); - -// WW: team select dropdown -document.addEventListener('change', e => { - if (e.target && e.target.id === 'wwTeamSelect') { - appState.wwTeamId = (e.target.value || '').toString(); - sendMessage('select_team', { team_id: appState.wwTeamId, target_game: appState.targetGame }); - } -}); - -// WW: save team button -document.addEventListener('click', e => { - if (e.target && e.target.id === 'saveTeamBtn') { - const name = (getEl('wwTeamName')?.value || '').toString().trim(); - if (!name) { updateStatus('Please enter a team name.', 'fail'); return; } - sendMessage('save_team', { - team_id: appState.wwTeamId || '', - team_name: name, - slot1: appState.wwTeamSlots[0] || '', - slot2: appState.wwTeamSlots[1] || '', - slot3: appState.wwTeamSlots[2] || '', - }); - } - if (e.target && e.target.id === 'newTeamBtn') { - appState.wwTeamId = ''; - appState.wwTeamSlots = ['', '', '']; - const teamSel = getEl('wwTeamSelect'); - if (teamSel) teamSel.value = ''; - const nameEl = getEl('wwTeamName'); - if (nameEl) nameEl.value = ''; - renderWwTeamEditor(); - _resolveTeamImagesToState(); - refreshTimelineIfLoaded(); - } -}); - -// WW: delete team (two-click confirm wired after DOM is ready) -const deleteTeamBtn = getEl('deleteTeamBtn'); -if (deleteTeamBtn) { - attachTwoClickConfirm(deleteTeamBtn, { - confirmText: 'Confirm delete', - onConfirm: () => { - if (!appState.wwTeamId) return; - sendMessage('delete_team', { team_id: appState.wwTeamId }); - } - }); -} - -// WW: dash input -document.addEventListener('input', e => { - if (e.target && e.target.id === 'wwDashImageInput') { - appState.wwDashImage = e.target.value.trim(); - const preview = getEl('wwDashPreview'); - if (preview) wwSetPreview(preview, e.target.value); - sendMessage('update_ww_dash', { dash_image: appState.wwDashImage }); - refreshTimelineIfLoaded(); - } -}); - -// Batched message handling (keeps UI smooth with lots of hits) - -function handleComboList(msg) { - const selector = getEl('comboSelector'); - const active = msg.active || ''; - selector.innerHTML = ''; - (msg.combos || []).forEach(name => { - const opt = document.createElement('option'); - opt.value = name; - opt.textContent = name; - selector.appendChild(opt); - }); - selector.value = active; -} - -const MESSAGE_HANDLERS = { - init: (msg) => initializeUI(msg), - combo_list: (msg) => handleComboList(msg), - combo_data: (msg) => setEditorFields(msg), - min_time: (msg) => updateMinTime(msg.text), - difficulty_update: (msg) => { - updateDifficulty(msg.text); - setDifficultyColor(getEl('difficultyDisplay'), msg.value); - }, - user_difficulty_update: (msg) => { - updateUserDifficulty(msg.text); - setDifficultyColor(getEl('userDifficultyDisplay'), msg.value); - }, - apm_update: (msg) => updateAPM(msg.text), - apm_max_update: (msg) => updateAPMMax(msg.text), - hold_begin: (msg) => startHoldAnimation(msg.required_ms), - hold_end: () => stopHoldAnimation(), - wait_begin: (msg) => startWaitAnimation(msg.required_ms), - wait_end: () => stopWaitAnimation(), - spam_begin: (msg) => startSpamAnimation(msg.required_ms), - spam_end: () => stopSpamAnimation(), - hit: (msg) => addResultRow(msg), - combo_dropped: (msg) => { - stopWaitAnimation(); - stopSpamAnimation(); - stopHoldAnimation(); - updateStatus(msg.input, msg.color || 'fail'); - addResultRow(msg); - }, - clear_results: () => clearAttemptLog(), - status: (msg) => updateStatus(msg.text, msg.color), - alert_notice: (msg) => { - const t = (msg.text || '').toString(); - if (t) window.alert(t); - }, - stat_update: (msg) => updateStats(msg.stats), - attempt_start: (msg) => addAttemptSeparator(msg.name, msg.attempt), - timeline_update: (msg) => updateTimeline(msg.steps, { focusLatest: !!msg.focus_latest }), - fail_update: (msg) => { - appState.lastFailByStep = msg.fail_by_step || {}; - refreshTimelineIfLoaded(); - }, - transcription_result: (msg) => { - const inputsEl = getEl('comboInputs'); - if (inputsEl) { - inputsEl.value = msg.inputs || ''; - if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); - } - }, -}; - -function handleMessage(msg) { - const fn = MESSAGE_HANDLERS[msg.type]; - if (fn) fn(msg); -} - -function processBatch() { - if (appState.batchQueue.length === 0) { - appState.isProcessingBatch = false; - return; - } - appState.isProcessingBatch = true; - - requestAnimationFrame(() => { - const batch = appState.batchQueue.splice(0, appState.batchQueue.length); - batch.forEach(handleMessage); - appState.isProcessingBatch = false; - if (appState.batchQueue.length > 0) processBatch(); - }); -} - -connectWebSocket(); diff --git a/static/app/combos.js b/static/app/combos.js new file mode 100644 index 0000000..72b02ec --- /dev/null +++ b/static/app/combos.js @@ -0,0 +1,669 @@ +// Combos page: the combo editor, the inputs highlighter, and record (transcribe) / replay (macro) modes. + +// UI Initialization +function initializeUI(data) { + setComboList(data.combos || [], data.active_combo || '', data.overview); + + // Clear live tables on init (fresh UI state) + getEl('resultsBody').innerHTML = ''; + + if (data.fail_by_step) appState.lastFailByStep = data.fail_by_step; + + const isNewCombo = data.editor && (data.editor.name || '').toString().trim() === ''; + const preserved = isNewCombo ? { + targetGame: appState.targetGame, + wwTeamId: appState.wwTeamId, + stepDisplayMode: appState.stepDisplayMode, + noFailMode: !!getEl('noFailMode')?.checked, + stepEditMode: !!getEl('stepEditToggle')?.checked, + collapseChainedPresses: !!getEl('collapseChainsToggle')?.checked, + keyImages: { ...appState.keyImages }, + } : null; + + if (data.editor) setEditorFields(data.editor); + if (preserved) { + appState.targetGame = preserved.targetGame; + appState.wwTeamId = preserved.wwTeamId; + appState.stepDisplayMode = preserved.stepDisplayMode; + const gameSelect = getEl('targetGameSelect'); + if (gameSelect) gameSelect.value = appState.targetGame; + const teamSelect = getEl('wwTeamSelect'); + if (teamSelect) teamSelect.value = appState.wwTeamId; + const stepToggle = getEl('stepDisplayToggle'); + if (stepToggle) stepToggle.checked = (appState.stepDisplayMode === 'images'); + const noFailEl = getEl('noFailMode'); + if (noFailEl) noFailEl.checked = preserved.noFailMode; + appState.stepEditMode = preserved.stepEditMode; + const stepEditToggle = getEl('stepEditToggle'); + if (stepEditToggle) stepEditToggle.checked = preserved.stepEditMode; + appState.collapseChainedPresses = preserved.collapseChainedPresses; + const collapseChainsToggle = getEl('collapseChainsToggle'); + if (collapseChainsToggle) collapseChainsToggle.checked = preserved.collapseChainedPresses; + appState.keyImages = preserved.keyImages; + syncGameUIVisibility(); + refreshTimelineIfLoaded(); + } + if (data.status) updateStatus(data.status.text, data.status.color); + if (data.stats !== undefined) updateStats(data.stats); + if (data.min_time !== undefined) updateMinTime(data.min_time); + if (data.difficulty !== undefined) updateDifficulty(data.difficulty); + if (data.user_difficulty !== undefined) updateUserDifficulty(data.user_difficulty); + if (data.apm !== undefined) updateAPM(data.apm); + if (data.apm_max !== undefined) updateAPMMax(data.apm_max); + setDifficultyColor(getEl('difficultyDisplay'), data.difficulty_value); + setDifficultyColor(getEl('userDifficultyDisplay'), data.user_difficulty_value); + if (data.timeline) updateTimeline(data.timeline); + + const noFailEl = getEl('noFailMode'); + if (noFailEl && !preserved) noFailEl.checked = !!data.no_fail_mode; + + const transcribeValidKeysEl = getEl('transcribeValidKeys'); + if (transcribeValidKeysEl && data.transcribe_valid_keys !== undefined) transcribeValidKeysEl.value = data.transcribe_valid_keys || ''; + const transcribeStartKeyEl = getEl('transcribeStartKey'); + if (transcribeStartKeyEl && data.transcribe_start_key !== undefined) transcribeStartKeyEl.value = data.transcribe_start_key || ''; + + const stripToggle = getEl('transcribeStripWaitToggle'); + if (stripToggle && data.transcribe_strip_wait_under_enabled !== undefined) { + stripToggle.checked = !!data.transcribe_strip_wait_under_enabled; + } + const stripMs = getEl('transcribeStripWaitMs'); + if (stripMs && data.transcribe_strip_wait_under_ms !== undefined) { + stripMs.value = data.transcribe_strip_wait_under_ms || ''; + } + + const macroStartKeyEl = getEl('macroStartKey'); + if (macroStartKeyEl && data.macro_start_key !== undefined) macroStartKeyEl.value = data.macro_start_key || ''; + const macroStopKeyEl = getEl('macroStopKey'); + if (macroStopKeyEl && data.macro_stop_key !== undefined) macroStopKeyEl.value = data.macro_stop_key || ''; + const macroSpamIntervalEl = getEl('macroSpamIntervalMs'); + if (macroSpamIntervalEl && data.macro_spam_interval_ms !== undefined) { + macroSpamIntervalEl.value = String(data.macro_spam_interval_ms || ''); + } +} + +// Split a combo string into top-level tokens (respects (), {}, []). +function splitTopLevelTokens(str) { + const out = []; + let buf = ''; + let paren = 0, brace = 0, bracket = 0; + for (const ch of (str || '')) { + if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === '{') brace++; + else if (ch === '}') brace = Math.max(0, brace - 1); + else if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + if (ch === ',' && paren === 0 && brace === 0 && bracket === 0) { + const t = buf.trim(); + if (t) out.push(t); + buf = ''; + } else { + buf += ch; + } + } + const t = buf.trim(); + if (t) out.push(t); + return out; +} + +// Extract the hold key from a hold(key, ...) token. +function _extractHoldKey(part) { + const tl = part.toLowerCase(); + if (!tl.startsWith('hold(') || !tl.endsWith(')')) return null; + const inner = part.slice('hold('.length, -1); + const args = splitTopLevelTokens(inner); + return args.length >= 1 ? args[0].trim().toLowerCase() : null; +} + +// Extract keys from inputs text +function extractKeysFromInputs() { + const txt = (getEl('comboInputs')?.value || '').toString(); + if (!txt.trim()) return []; + + const parts = splitTopLevelTokens(txt).map(x => x.toLowerCase()); + const keys = new Set(); + + parts.forEach(part => { + // hold(key, time) or hold(key, time, {body}) — new or existing form + if (/^hold\s*\(/i.test(part)) { + const hk = _extractHoldKey(part); + if (hk) keys.add(hk); + // Also extract keys from {body} if present + const bodyM = part.match(/\{([^}]*)\}\s*\)$/); + if (bodyM) { + splitTopLevelTokens(bodyM[1]).forEach(bi => { + const bit = bi.trim().toLowerCase(); + if (!/^wait[_a-z]*[:(\s]/i.test(bit)) keys.add(bit); + }); + } + return; + } + // key{time} shorthand + let m = part.match(/^([^{]+)\{/); + if (m) { + keys.add(m[1].trim()); + return; + } + // wait(...) -> skip + if (/^wait[_a-z]*[:(\s]/i.test(part)) return; + // [group] -> extract items using top-level split + m = part.match(/^\[(.+)\]$/s); + if (m) { + splitTopLevelTokens(m[1]).forEach(gi => { + const gg = gi.trim().toLowerCase(); + if (/^hold\s*\(/i.test(gg)) { + const hk = _extractHoldKey(gg); + if (hk) keys.add(hk); + return; + } + let mm = gg.match(/^([^{]+)\{/); + if (mm) { keys.add(mm[1].trim()); return; } + mm = gg.match(/^wait\(\s*([^,]+)\s*,/i); + if (mm) { keys.add(mm[1].trim()); return; } + if (!/^wait[_a-z]*[:(\s]/i.test(gg)) keys.add(gg); + }); + return; + } + // Plain key + keys.add(part); + }); + + return Array.from(keys).sort(); +} + +function readKeyImagesFromUI() { + const container = getEl('keyImagesEditor'); + if (!container) return; + const inputs = container.querySelectorAll('input[data-key]'); + const next = {}; + inputs.forEach(inp => { + const k = (inp.getAttribute('data-key') || '').trim().toLowerCase(); + const url = (inp.value || '').toString().trim(); + if (k && url) next[k] = url; + }); + appState.keyImages = next; +} + +function renderKeyImagesEditor() { + // Generic mode only + if (appState.targetGame === 'wuthering_waves') return; + + readKeyImagesFromUI(); + const container = getEl('keyImagesEditor'); + if (!container) return; + container.innerHTML = ''; + + const keys = extractKeysFromInputs(); + if (keys.length === 0) { + container.innerHTML = '
Enter inputs above to see key image fields.
'; + return; + } + + keys.forEach(k => { + const row = document.createElement('div'); + row.className = 'key-image-row'; + + const label = document.createElement('span'); + label.className = 'key-image-label'; + label.textContent = k.toUpperCase(); + + const input = document.createElement('input'); + input.type = 'text'; + input.setAttribute('data-key', k); + input.placeholder = 'https://... or emoji'; + input.value = (appState.keyImages[k] || '').toString(); + + const preview = document.createElement('div'); + preview.className = 'key-image-preview'; + const v = (appState.keyImages[k] || '').toString().trim(); + if (v) { + preview.style.display = 'flex'; + if (/^https?:\/\//i.test(v)) { + preview.innerHTML = ``; + } else { + preview.innerHTML = `${escapeHtml(v)}`; + } + } else { + preview.style.display = 'none'; + } + + input.addEventListener('input', () => { + const url = input.value.trim(); + if (url) { + appState.keyImages[k] = url; + preview.style.display = 'flex'; + if (/^https?:\/\//i.test(url)) { + preview.innerHTML = ``; + } else { + preview.innerHTML = `${escapeHtml(url)}`; + } + } else { + delete appState.keyImages[k]; + preview.style.display = 'none'; + } + }); + + row.appendChild(label); + row.appendChild(input); + row.appendChild(preview); + container.appendChild(row); + }); +} + +// Demo video: normalize YouTube link to embed URL +function getYouTubeEmbedUrl(url) { + const s = (url || '').toString().trim(); + if (!s) return null; + try { + // youtu.be/VIDEO_ID + const short = s.match(/youtu\.be\/([a-zA-Z0-9_-]{10,})/); + if (short) return 'https://www.youtube.com/embed/' + short[1]; + // youtube.com/watch?v=VIDEO_ID or youtube.com/embed/VIDEO_ID + const u = new URL(s.startsWith('http') ? s : 'https://' + s); + if (u.hostname.replace(/^www\./, '') === 'youtube.com') { + const v = u.searchParams.get('v') || (u.pathname || '').split('/').pop(); + if (v && /^[a-zA-Z0-9_-]{10,}$/.test(v)) return 'https://www.youtube.com/embed/' + v; + } + } catch (_) {} + return null; +} + +function updateDemoVideoEmbed(url) { + const wrap = getEl('demoVideoEmbedWrap'); + const iframe = getEl('demoVideoEmbed'); + if (!wrap || !iframe) return; + const embedUrl = getYouTubeEmbedUrl(url); + if (embedUrl) { + iframe.src = embedUrl; + wrap.classList.remove('hidden'); + } else { + iframe.src = ''; + wrap.classList.add('hidden'); + } +} + +// Editor fields update (from backend) +function setEditorFields(data) { + getEl('comboName').value = data.name || ''; + const inputsEl = getEl('comboInputs'); + if (inputsEl) { + inputsEl.value = data.inputs || ''; + if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); + } + getEl('comboEnders').value = data.enders || ''; + getEl('comboExpectedTime').value = data.expected_time || ''; + getEl('comboUserDifficulty').value = data.user_difficulty || ''; + + const demoVideoEl = getEl('comboDemoVideo'); + if (demoVideoEl) { + demoVideoEl.value = (data.demo_video || '').toString().trim(); + updateDemoVideoEmbed(demoVideoEl.value); + } + + appState.stepDisplayMode = (data.step_display_mode || 'images').toString().trim().toLowerCase(); + if (!['icons', 'images'].includes(appState.stepDisplayMode)) appState.stepDisplayMode = 'images'; + const toggle = getEl('stepDisplayToggle'); + if (toggle) toggle.checked = (appState.stepDisplayMode === 'images'); + + appState.keyImages = (typeof data.key_images === 'object' && data.key_images !== null) ? { ...data.key_images } : {}; + + // Target game & WW data + appState.targetGame = normalizeTargetGame(data.target_game || 'generic'); + const gameSelect = getEl('targetGameSelect'); + if (gameSelect) gameSelect.value = appState.targetGame; + + // WW character library + const charsList = Array.isArray(data.ww_characters) ? data.ww_characters : []; + appState.wwCharacters = {}; + charsList.forEach(c => { + if (c && c.name_key) appState.wwCharacters[c.name_key] = c; + }); + + // WW teams list (now includes slot1/slot2/slot3) + appState.wwTeams = Array.isArray(data.ww_teams) ? [...data.ww_teams] : []; + + // Selected team + appState.wwTeamId = (data.ww_team_id || '').toString().trim(); + + // Team slots + const slots = data.ww_team_slots || {}; + appState.wwTeamSlots = [ + (slots.slot1 || '').toString(), + (slots.slot2 || '').toString(), + (slots.slot3 || '').toString(), + ]; + + // Global dash + appState.wwDashImage = (data.ww_dash_image || data.ww_team_dash_image || '').toString().trim(); + + // Resolved images for timeline rendering + appState.wwSwapImages = ensureWwSlotShape(data.ww_team_swap_images); + appState.wwLmbImages = ensureWwSlotShape(data.ww_team_lmb_images); + appState.wwAbilityImages = ensureWwAbilityShape(data.ww_team_ability_images); + + // Keep the character editor selection across WW refreshes (e.g. changing team sends + // combo_data). Only clear if that character no longer exists in the library. + const prevCharKey = appState.wwCurrentChar; + if (prevCharKey && !appState.wwCharacters[prevCharKey]) { + appState.wwCurrentChar = null; + } + + syncGameUIVisibility(); +} + +const noFailModeEl = getEl('noFailMode'); +if (noFailModeEl) { + noFailModeEl.addEventListener('change', () => { + sendMessage('set_no_fail', { enabled: noFailModeEl.checked }); + }); +} + +// Save/Update button +const saveBtn = getEl('saveBtn'); +if (saveBtn) { + saveBtn.addEventListener('click', () => { + readKeyImagesFromUI(); + + const name = (getEl('comboName')?.value || '').toString(); + const inputs = (getEl('comboInputs')?.value || '').toString(); + const enders = (getEl('comboEnders')?.value || '').toString(); + const expectedTime = (getEl('comboExpectedTime')?.value || '').toString(); + const userDifficulty = (getEl('comboUserDifficulty')?.value || '').toString(); + const toggle = getEl('stepDisplayToggle'); + const mode = toggle?.checked ? 'images' : 'icons'; + + const demoVideo = (getEl('comboDemoVideo')?.value || '').toString().trim(); + if (name.trim()) appState.pendingToast = `Saved ${name.trim()}`; + sendMessage('save_combo', { + name, + inputs, + enders, + expected_time: expectedTime, + user_difficulty: userDifficulty, + step_display_mode: mode, + key_images: appState.keyImages, + demo_video: demoVideo, + target_game: appState.targetGame, + ww_team_id: appState.wwTeamId || '' + }); + }); +} + +// Ctrl/Cmd+S: save the current combo instead of triggering the browser's save-page dialog +document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 's') { + e.preventDefault(); + saveBtn?.click(); + } +}); + +// Ctrl/Cmd+Z: undo the last Edit Steps mutation (delete, reorder, or inline field edit) +document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { + if (!appState.stepEditMode || appState.editStepsUndoStack.length === 0) return; + e.preventDefault(); + undoLastEditStep(); + } +}); + +// Demo video input: update embed preview on change +const comboDemoVideoEl = getEl('comboDemoVideo'); +if (comboDemoVideoEl) { + comboDemoVideoEl.addEventListener('input', () => updateDemoVideoEmbed(comboDemoVideoEl.value)); + comboDemoVideoEl.addEventListener('change', () => updateDemoVideoEmbed(comboDemoVideoEl.value)); +} + +// Practice it: jump to the Practice page with this combo +getEl('practiceBtn')?.addEventListener('click', () => showPage('practice')); + +// New combo button +const newBtn = getEl('newBtn'); +if (newBtn) { + newBtn.addEventListener('click', () => { + sendMessage('new_combo'); + }); +} + +// Delete combo button +const deleteBtn = getEl('deleteBtn'); +if (deleteBtn) { + attachTwoClickConfirm(deleteBtn, { + confirmText: 'Click again to delete', + onConfirm: () => { + const name = (getEl('comboName')?.value || '').toString(); + if (name) { + sendMessage('delete_combo', { name }); + } + } + }); +} + +function sendTranscribeMode() { + const toggle = getEl('transcribeModeToggle'); + const validInput = getEl('transcribeValidKeys'); + const startInput = getEl('transcribeStartKey'); + const stripToggle = getEl('transcribeStripWaitToggle'); + const stripMsEl = getEl('transcribeStripWaitMs'); + sendMessage('set_transcribe_mode', { + enabled: !!(toggle && toggle.checked), + valid_keys: (validInput && validInput.value.trim()) || '', + start_key: (startInput && startInput.value.trim()) || '', + strip_wait_under_enabled: !!(stripToggle && stripToggle.checked), + strip_wait_under_ms: (stripMsEl && stripMsEl.value.trim()) || '' + }); +} + +const transcribeModeToggle = getEl('transcribeModeToggle'); +const transcribeValidKeysWrap = getEl('transcribeValidKeysWrap'); +const transcribeValidKeysInput = getEl('transcribeValidKeys'); +const transcribeStartKeyInput = getEl('transcribeStartKey'); +const transcribeStripWaitToggle = getEl('transcribeStripWaitToggle'); +const transcribeStripWaitMs = getEl('transcribeStripWaitMs'); + +function transcribePersistIfOn() { + if (transcribeModeToggle && transcribeModeToggle.checked) sendTranscribeMode(); +} +if (transcribeModeToggle) { + transcribeModeToggle.addEventListener('change', () => { + if (transcribeModeToggle.checked && macroModeToggle && macroModeToggle.checked) { + macroModeToggle.checked = false; + if (macroSettingsWrap) macroSettingsWrap.classList.add('hidden'); + sendMacroMode(); + } + if (transcribeValidKeysWrap) transcribeValidKeysWrap.classList.toggle('hidden', !transcribeModeToggle.checked); + sendTranscribeMode(); + renderRailStatus(); + }); +} +if (transcribeValidKeysInput) { + transcribeValidKeysInput.addEventListener('blur', transcribePersistIfOn); + transcribeValidKeysInput.addEventListener('input', transcribePersistIfOn); +} +if (transcribeStartKeyInput) { + transcribeStartKeyInput.addEventListener('blur', transcribePersistIfOn); + transcribeStartKeyInput.addEventListener('input', transcribePersistIfOn); +} +if (transcribeStripWaitToggle) { + transcribeStripWaitToggle.addEventListener('change', transcribePersistIfOn); +} +if (transcribeStripWaitMs) { + transcribeStripWaitMs.addEventListener('blur', transcribePersistIfOn); + transcribeStripWaitMs.addEventListener('input', transcribePersistIfOn); +} +if (transcribeValidKeysWrap && transcribeModeToggle) { + transcribeValidKeysWrap.classList.toggle('hidden', !transcribeModeToggle.checked); +} + +// --- Macro replay mode --- +function sendMacroMode() { + const toggle = getEl('macroModeToggle'); + const startInput = getEl('macroStartKey'); + const stopInput = getEl('macroStopKey'); + const spamIntervalInput = getEl('macroSpamIntervalMs'); + sendMessage('set_macro_mode', { + enabled: !!(toggle && toggle.checked), + start_key: (startInput && startInput.value.trim()) || '', + stop_key: (stopInput && stopInput.value.trim()) || '', + spam_interval_ms: (spamIntervalInput && spamIntervalInput.value.trim()) || '', + }); +} + +function macroPersistIfOn() { + if (getEl('macroModeToggle')?.checked) sendMacroMode(); +} + +const macroModeToggle = getEl('macroModeToggle'); +const macroSettingsWrap = getEl('macroSettingsWrap'); +const macroStartKeyInput = getEl('macroStartKey'); +const macroStopKeyInput = getEl('macroStopKey'); +const macroSpamIntervalInput = getEl('macroSpamIntervalMs'); + +if (macroModeToggle) { + macroModeToggle.addEventListener('change', () => { + if (macroModeToggle.checked && transcribeModeToggle && transcribeModeToggle.checked) { + transcribeModeToggle.checked = false; + if (transcribeValidKeysWrap) transcribeValidKeysWrap.classList.add('hidden'); + sendTranscribeMode(); + } + if (macroSettingsWrap) macroSettingsWrap.classList.toggle('hidden', !macroModeToggle.checked); + sendMacroMode(); + renderRailStatus(); + }); +} +if (macroStartKeyInput) { + macroStartKeyInput.addEventListener('blur', macroPersistIfOn); + macroStartKeyInput.addEventListener('input', macroPersistIfOn); +} +if (macroStopKeyInput) { + macroStopKeyInput.addEventListener('blur', macroPersistIfOn); + macroStopKeyInput.addEventListener('input', macroPersistIfOn); +} +if (macroSpamIntervalInput) { + macroSpamIntervalInput.addEventListener('blur', macroPersistIfOn); + macroSpamIntervalInput.addEventListener('input', macroPersistIfOn); +} +if (macroSettingsWrap && macroModeToggle) { + macroSettingsWrap.classList.toggle('hidden', !macroModeToggle.checked); +} + +/** + * Tokenize combo input string for syntax highlighting. Returns HTML with spans. + */ +function tokenizeComboInput(text) { + const escape = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const tokens = []; + let i = 0; + const len = text.length; + while (i < len) { + // wait(key, duration) - animation lock + if (text.slice(i).match(/^wait\s*\(/)) { + const start = i; + i += text.slice(i).match(/^wait\s*\(/)[0].length; + let depth = 1; + while (i < len && depth) { + if (text[i] === '(') depth++; + else if (text[i] === ')') depth--; + i++; + } + tokens.push({ type: 'anim-wait', text: text.slice(start, i) }); + continue; + } + // hold(key, duration) + if (text.slice(i).match(/^hold\s*\(/)) { + const start = i; + i += text.slice(i).match(/^hold\s*\(/)[0].length; + let depth = 1; + while (i < len && depth) { + if (text[i] === '(') depth++; + else if (text[i] === ')') depth--; + i++; + } + tokens.push({ type: 'hold', text: text.slice(start, i) }); + continue; + } + // wait:duration (soft wait) + if (text.slice(i).match(/^wait\s*:/)) { + const start = i; + i += text.slice(i).match(/^wait\s*:/)[0].length; + while (i < len && /[^\s,\[\]\{\}]/.test(text[i])) i++; + tokens.push({ type: 'wait', text: text.slice(start, i) }); + continue; + } + // optional -key + if (text[i] === '-' && i + 1 < len && /[a-zA-Z0-9]/.test(text[i + 1])) { + const start = i; + i++; + while (i < len && /[a-zA-Z0-9]/.test(text[i])) i++; + tokens.push({ type: 'optional', text: text.slice(start, i) }); + continue; + } + // brackets and braces + if (text[i] === '[' || text[i] === ']') { + tokens.push({ type: 'bracket', text: text[i] }); + i++; + continue; + } + if (text[i] === '{' || text[i] === '}') { + tokens.push({ type: 'sequence', text: text[i] }); + i++; + continue; + } + if (text[i] === ',') { + tokens.push({ type: 'punct', text: ',' }); + i++; + continue; + } + // default: one character (preserve newlines/spaces) + const ch = text[i]; + tokens.push({ type: 'default', text: ch }); + i++; + } + return tokens.map(t => { + const escaped = escape(t.text); + if (t.type === 'default') return escaped; + return `${escaped}`; + }).join(''); +} + +function updateComboInputHighlight() { + const ta = getEl('comboInputs'); + const mirror = getEl('comboInputHighlight'); + if (!ta || !mirror) return; + const raw = (ta.value || ''); + mirror.innerHTML = raw ? tokenizeComboInput(raw) : ''; + mirror.scrollTop = ta.scrollTop; + mirror.scrollLeft = ta.scrollLeft; +} + +const inputsEl = getEl('comboInputs'); +if (inputsEl) { + let t = null; + inputsEl.addEventListener('input', () => { + updateComboInputHighlight(); + if (t) clearTimeout(t); + t = setTimeout(() => { + renderKeyImagesEditor(); + }, 150); + }); + inputsEl.addEventListener('scroll', () => { + const mirror = getEl('comboInputHighlight'); + if (mirror) { + mirror.scrollTop = inputsEl.scrollTop; + mirror.scrollLeft = inputsEl.scrollLeft; + } + }); + // Initial highlight if textarea already has content (e.g. restored state) + updateComboInputHighlight(); +} + +const keyImagesDetails = getEl('keyImagesDetails'); +if (keyImagesDetails) { + keyImagesDetails.addEventListener('toggle', () => { + // Ensure UI is up-to-date when opening/closing + renderKeyImagesEditor(); + }); +} diff --git a/static/app/core.js b/static/app/core.js new file mode 100644 index 0000000..ebc7761 --- /dev/null +++ b/static/app/core.js @@ -0,0 +1,108 @@ +// Combo Tracker main page, split by page. Load order (see index.html): +// ../ws.js, ../ww_moves.js, core.js, timeline.js, practice.js, combos.js, teams.js, shell.js +// Every file is a classic script sharing one global scope; shell.js opens the backend connection last. + +const getEl = (id) => document.getElementById(id); + +/** Waits with duration <= this (ms) get class "short-wait" and a duller yellow border in CSS. Search for "short-wait" in style.css. */ +const SHORT_WAIT_MS = 150; + +/** Per-browser display preference; storage can be unavailable (private windows, OBS), so fall back quietly. */ +function readStoredFlag(key, fallback) { + try { + const v = localStorage.getItem(key); + return v === null ? fallback : v === '1'; + } catch (_) { + return fallback; + } +} + +function writeStoredFlag(key, value) { + try { localStorage.setItem(key, value ? '1' : '0'); } catch (_) { /* ignore */ } +} + +/** Character names for team slots '1'/'2'/'3' of the selected team. */ +function wwSlotNames() { + const out = {}; + WW_SLOTS.forEach((slot, i) => { + const key = (appState.wwTeamSlots[i] || '').toString(); + const ch = key ? appState.wwCharacters[key] : null; + if (ch && ch.name) out[slot] = ch.name; + }); + return out; +} + +/** Backend connection; set by shell.js once every page's code has loaded. */ +let tracker = null; + +function sendMessage(type, payload = {}) { + if (tracker) tracker.send(type, payload); +} + +// Single app state (replaces scattered globals) +const appState = { + stepDisplayMode: 'images', + keyImages: {}, + lastTimelineSteps: null, + lastFailByStep: {}, + showFailCount: false, + collapseChainedPresses: true, + showMoveNames: readStoredFlag('showMoveNames', true), + autoScrollEnabled: false, + stepEditMode: true, + editStepsUndoStack: [], + targetGame: 'wuthering_waves', + wwAbilityImages: { "1": {}, "2": {}, "3": {} }, + wwSwapImages: { "1": "", "2": "", "3": "" }, + wwLmbImages: { "1": "", "2": "", "3": "" }, + wwDashImage: "", + wwTeams: [], + wwTeamId: '', + wwTeamSlots: ["", "", ""], + wwCharacters: {}, + wwCurrentChar: null, + teamEdit: { id: '', name: '', slots: ['', '', ''], syncedSnap: '', pendingName: '' }, + pendingToast: '', + batchQueue: [], + isProcessingBatch: false, + avgStepMsByPosition: [], + // Shell (rail, header, History): one row per combo from the backend's "overview". + comboNames: [], + activeCombo: '', + overview: [], + connected: false, +}; + +// Two-click confirm pattern +function attachTwoClickConfirm(btn, opts) { + let armed = false; + let timer = null; + const origText = btn.textContent; + btn.addEventListener('click', () => { + if (armed) { + armed = false; + clearTimeout(timer); + btn.textContent = origText; + if (opts.onConfirm) opts.onConfirm(); + } else { + armed = true; + btn.textContent = opts.confirmText || 'Confirm?'; + timer = setTimeout(() => { + armed = false; + btn.textContent = origText; + }, 3000); + } + }); +} + +function scrollToBottom(el) { + const target = (typeof el === 'string') ? getEl(el) : el; + if (!target) return; + target.scrollTop = target.scrollHeight; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/static/app/practice.js b/static/app/practice.js new file mode 100644 index 0000000..ae01117 --- /dev/null +++ b/static/app/practice.js @@ -0,0 +1,413 @@ +// Practice page: stat cards, status line, attempt log and the Combo Steps toggles. + +// Status display +function updateStatus(text, color) { + const el = getEl('statusDisplay'); + if (!el) return; + el.textContent = text || 'Status: Ready'; + el.className = 'status-' + (color || 'neutral'); +} + +// Stats: the backend sends "Label: value" texts; cards show just the value. +function setStatValue(id, text) { + const el = getEl(id)?.querySelector('.stat-value'); + if (!el) return; + const raw = (text || '').toString(); + const i = raw.indexOf(':'); + const value = (i >= 0 ? raw.slice(i + 1) : raw).trim(); + el.textContent = value || '—'; + el.title = raw; +} + +// e.g. "Stats: 3 success / 1 fail (75%) | Best: 12.3s | Avg: 13.0s" +function updateStats(text) { + const parts = (text || '').toString().replace(/^Stats:\s*/, '').split('|').map(s => s.trim()); + const result = (parts[0] || '').replace(/ success \/ /, ' / ').replace(/ fail/, ''); + setStatValue('statsResult', /^0 \/ 0\b/.test(result) ? '' : result); // no attempts yet + setStatValue('statsBest', parts[1] || ''); + setStatValue('statsAvg', parts[2] || ''); +} + +function updateMinTime(text) { + // Drop the "(53700ms)" repeat of the value to keep the card short. + setStatValue('minTimeDisplay', (text || '').toString().replace(/\s*\(\d+ms\)\s*$/, '')); +} + +function updateDifficulty(text) { + setStatValue('difficultyDisplay', text); +} + +function updateUserDifficulty(text) { + setStatValue('userDifficultyDisplay', text); +} + +function updateAPM(text) { + setStatValue('apmDisplay', text); +} + +function updateAPMMax(text) { + setStatValue('apmMaxDisplay', text); +} + +function setDifficultyColor(el, value) { + if (!el) return; + el.classList.remove('diff-easy', 'diff-med', 'diff-hard', 'diff-insane'); + if (value === null || value === undefined || value === '') return; // unrated, not "easy" + const v = Number(value); + if (!Number.isFinite(v)) return; + if (v < 3) el.classList.add('diff-easy'); + else if (v < 6) el.classList.add('diff-med'); + else if (v < 8) el.classList.add('diff-hard'); + else el.classList.add('diff-insane'); +} + +// Attempt log +const LOG_ATTEMPTS_STORAGE_KEY = 'logAttemptsEnabled'; +let logAttemptsEnabled = false; + +function isLogAttemptsEnabled() { + return logAttemptsEnabled; +} + +function setLogAttemptsEnabled(enabled) { + logAttemptsEnabled = !!enabled; + try { + localStorage.setItem(LOG_ATTEMPTS_STORAGE_KEY, logAttemptsEnabled ? '1' : '0'); + } catch (_) { + // localStorage unavailable; runtime-only is fine. + } + const resultsTable = getEl('resultsTable'); + if (resultsTable) resultsTable.classList.toggle('hidden', !logAttemptsEnabled); + getEl('attemptLogOffNote')?.classList.toggle('hidden', logAttemptsEnabled); + renderAvgSplitsOnTimeline(); +} + +function clearAttemptLog() { + getEl('resultsBody').innerHTML = ''; + appState.avgStepMsByPosition = []; + renderAvgSplitsOnTimeline(); +} + +function escapeMarkdownCell(text) { + return (text || '').toString().replace(/\|/g, '\\|'); +} + +function buildAttemptMarkdownTable(separatorRow) { + if (!separatorRow) return ''; + const lines = [ + '| Input | Step Time (ms) | Total (ms) | Avg Step Time (ms) |', + '| ----- | ---------- | ---------- | -------------- |', + ]; + + let cur = separatorRow.nextElementSibling; + while (cur) { + if (cur.classList.contains('separator')) break; + if (cur.classList.contains('result-row')) { + const cells = cur.querySelectorAll('span'); + if (cells.length >= 4) { + const input = escapeMarkdownCell(cells[0].textContent?.trim() || ''); + const split = escapeMarkdownCell(cells[1].textContent?.trim() || '—'); + const total = escapeMarkdownCell(cells[2].textContent?.trim() || '—'); + const avgSplit = escapeMarkdownCell(cells[3].textContent?.trim() || '—'); + lines.push(`| ${input} | ${split} | ${total} | ${avgSplit} |`); + } + } + cur = cur.nextElementSibling; + } + + return lines.length > 2 ? lines.join('\n') : ''; +} + +async function copyAttemptToClipboard(separatorRow, copyBtn) { + const markdown = buildAttemptMarkdownTable(separatorRow); + if (!markdown) return; + try { + await navigator.clipboard.writeText(markdown); + } catch (_) { + const ta = document.createElement('textarea'); + ta.value = markdown; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + if (copyBtn) { + const originalTitle = copyBtn.title; + copyBtn.title = 'Copied!'; + setTimeout(() => { + copyBtn.title = originalTitle; + }, 1200); + } +} + +function recalcAttemptAvgSplits() { + const body = getEl('resultsBody'); + if (!body) return; + + // Group result rows by attempt (separated by .separator divs) + const attempts = []; + let current = null; + let el = body.firstElementChild; + while (el) { + if (el.classList.contains('separator')) { + current = []; + attempts.push(current); + } else if (el.classList.contains('result-row') && current !== null) { + current.push(el); + } + el = el.nextElementSibling; + } + + const maxLen = attempts.reduce((m, a) => Math.max(m, a.length), 0); + const avgByPos = []; + + for (let pos = 0; pos < maxLen; pos++) { + const splits = []; + for (const attempt of attempts) { + if (pos < attempt.length) { + const cells = attempt[pos].querySelectorAll('span'); + const val = parseFloat(cells[1]?.textContent?.trim()); + if (Number.isFinite(val)) splits.push(val); + } + } + const avg = splits.length > 0 + ? splits.reduce((s, v) => s + v, 0) / splits.length + : null; + avgByPos.push(avg); + + const avgText = avg !== null ? avg.toFixed(1) : '—'; + for (const attempt of attempts) { + if (pos < attempt.length) { + const avgCell = attempt[pos].querySelector('.result-avg-split'); + if (avgCell) avgCell.textContent = avgText; + } + } + } + + appState.avgStepMsByPosition = avgByPos; + renderAvgSplitsOnTimeline(); +} + +function renderAvgSplitsOnTimeline() { + const container = getEl('comboTimeline'); + if (!container) return; + + // Clear all existing avg overlays + container.querySelectorAll('.step-avg-split').forEach(e => e.remove()); + + if (!appState.showFailCount) return; + + const avgs = appState.avgStepMsByPosition || []; + if (avgs.length === 0) return; + + // Top-level tiles in DOM order (skip chain-dash connectors) + const tiles = [...container.children].filter( + e => !e.classList.contains('timeline-chain-dash') + ); + + const parseStepIndices = (tile, fallbackIdx) => { + const raw = (tile.dataset.stepIndices || '').trim(); + if (!raw) return [fallbackIdx]; + const parsed = raw.split(',') + .map(v => Number.parseInt(v, 10)) + .filter(v => Number.isFinite(v) && v >= 0); + return parsed.length > 0 ? parsed : [fallbackIdx]; + }; + + tiles.forEach((tile, idx) => { + const indices = parseStepIndices(tile, idx); + const values = indices + .map(i => avgs[i]) + .filter(v => Number.isFinite(v)); + if (values.length === 0) return; + const tileAvg = values.reduce((s, v) => s + v, 0); + const span = document.createElement('span'); + span.className = 'step-avg-split'; + span.textContent = `avg step ${tileAvg.toFixed(1)}ms`; + tile.appendChild(span); + }); +} + +function deleteAttemptBlock(separatorRow) { + if (!separatorRow || !separatorRow.parentElement) return; + const toRemove = [separatorRow]; + let cur = separatorRow.nextElementSibling; + while (cur) { + if (cur.classList.contains('separator')) break; + toRemove.push(cur); + cur = cur.nextElementSibling; + } + toRemove.forEach((el) => el.remove()); + recalcAttemptAvgSplits(); +} + +function addAttemptSeparator(name, attempt) { + if (!isLogAttemptsEnabled()) return; + const body = getEl('resultsBody'); + if (!body) return; + const row = document.createElement('div'); + row.className = 'result-row separator'; + + const label = document.createElement('span'); + label.className = 'attempt-separator-label'; + label.textContent = `—— ${name} | Attempt ${attempt} ——`; + + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'attempt-copy-btn subtle icon-btn'; + copyBtn.title = 'Copy this attempt as table'; + copyBtn.setAttribute('aria-label', 'Copy this attempt as table'); + copyBtn.innerHTML = ''; + copyBtn.addEventListener('click', () => copyAttemptToClipboard(row, copyBtn)); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'attempt-delete-btn danger subtle icon-btn'; + deleteBtn.title = 'Delete this attempt from log'; + deleteBtn.setAttribute('aria-label', 'Delete this attempt from log'); + deleteBtn.innerHTML = ''; + deleteBtn.addEventListener('click', () => deleteAttemptBlock(row)); + + row.appendChild(label); + row.appendChild(copyBtn); + row.appendChild(deleteBtn); + body.appendChild(row); + scrollToBottom('resultsTable'); +} + +function addResultRow(data) { + if (!isLogAttemptsEnabled()) return; + const body = getEl('resultsBody'); + if (!body) return; + const row = document.createElement('div'); + row.className = 'result-row'; + + const stepMs = (data.step_ms != null) ? data.step_ms : data.split_ms; + if (data.fail === true || stepMs === 'FAIL' || data.total_ms === 'FAIL') { + row.classList.add('fail'); + } else { + row.classList.add('success'); + } + + row.innerHTML = ` + ${escapeHtml(data.input || '')} + ${stepMs != null ? stepMs : '—'} + ${data.total_ms != null ? data.total_ms : '—'} + — + `; + + body.appendChild(row); + recalcAttemptAvgSplits(); + scrollToBottom('resultsTable'); +} + +// Clear history button +const clearBtn = getEl('clearBtn'); +if (clearBtn) { + attachTwoClickConfirm(clearBtn, { + confirmText: 'Click again to clear', + onConfirm: () => { + sendMessage('clear_history'); + } + }); +} + +// Log attempts toggle: when off (default), suppress new entries in the attempt log. +const logAttemptsToggleEl = getEl('logAttemptsToggle'); +if (logAttemptsToggleEl) { + let initial = false; + try { + initial = localStorage.getItem(LOG_ATTEMPTS_STORAGE_KEY) === '1'; + } catch (_) { + initial = false; + } + logAttemptsToggleEl.checked = initial; + setLogAttemptsEnabled(initial); + logAttemptsToggleEl.addEventListener('change', () => { + setLogAttemptsEnabled(logAttemptsToggleEl.checked); + }); +} + +// Wire up editor UI events +const stepToggleEl = getEl('stepDisplayToggle'); +if (stepToggleEl) { + stepToggleEl.addEventListener('change', () => { + appState.stepDisplayMode = stepToggleEl.checked ? 'images' : 'icons'; + syncGameUIVisibility(); + refreshTimelineIfLoaded(); + }); +} + +const autoScrollToggleEl = getEl('autoScrollToggle'); +if (autoScrollToggleEl) { + if (document.body.classList.contains('timeline-window-view')) { + autoScrollToggleEl.checked = true; + setAutoScrollEnabled(true); + } else { + setAutoScrollEnabled(autoScrollToggleEl.checked); + } + autoScrollToggleEl.addEventListener('change', () => { + setAutoScrollEnabled(autoScrollToggleEl.checked); + refreshTimelineIfLoaded(); + }); +} + +const showFailCountEl = getEl('showFailCount'); +if (showFailCountEl) { + showFailCountEl.addEventListener('change', () => { + appState.showFailCount = showFailCountEl.checked; + refreshTimelineIfLoaded(); + }); +} + +const moveNamesToggleEl = getEl('moveNamesToggle'); +if (moveNamesToggleEl) { + moveNamesToggleEl.checked = !!appState.showMoveNames; + moveNamesToggleEl.addEventListener('change', () => { + appState.showMoveNames = moveNamesToggleEl.checked; + writeStoredFlag('showMoveNames', appState.showMoveNames); + refreshTimelineIfLoaded(); + }); +} + +const stepEditToggleEl = getEl('stepEditToggle'); +if (stepEditToggleEl) { + stepEditToggleEl.checked = !!appState.stepEditMode; + stepEditToggleEl.addEventListener('change', () => { + appState.stepEditMode = stepEditToggleEl.checked; + refreshTimelineIfLoaded(); + }); +} + +const collapseChainsToggleEl = getEl('collapseChainsToggle'); +if (collapseChainsToggleEl) { + collapseChainsToggleEl.checked = !!appState.collapseChainedPresses; + collapseChainsToggleEl.addEventListener('change', () => { + appState.collapseChainedPresses = collapseChainsToggleEl.checked; + refreshTimelineIfLoaded(); + }); +} + +const legendBtn = getEl('legendBtn'); +if (legendBtn) { + legendBtn.addEventListener('click', () => { + const panel = getEl('legendPanel'); + if (!panel) return; + const open = panel.classList.toggle('hidden') === false; + legendBtn.setAttribute('aria-expanded', String(open)); + }); +} + +const openTimelineWindowBtn = getEl('openTimelineWindowBtn'); +if (openTimelineWindowBtn) { + openTimelineWindowBtn.addEventListener('click', () => { + window.open(getTimelineUrl(), 'combo-tracker-timeline', 'width=900,height=400,menubar=no,toolbar=no'); + }); +} + +window.addEventListener('resize', () => { + if (appState.autoScrollEnabled) applyAutoScroll(); +}); diff --git a/static/app/shell.js b/static/app/shell.js new file mode 100644 index 0000000..a13f779 --- /dev/null +++ b/static/app/shell.js @@ -0,0 +1,513 @@ +// App shell: page navigation, the left rail and its status card, the header combo picker, +// the Combos list, History, Settings, toasts, and the backend message dispatch. Loads last. + +const PAGES = { + practice: { title: 'Practice', overline: 'Now practicing' }, + combos: { title: 'Combos', overline: 'Edit and record' }, + teams: { title: 'Teams', overline: 'Your teams' }, + characters: { title: 'Characters', overline: 'Moves and rotations' }, + history: { title: 'History', overline: 'Every combo' }, + settings: { title: 'Settings', overline: 'App' }, +}; +const PAGE_STORAGE_KEY = 'ctPage'; +const isTimelineView = document.body.classList.contains('timeline-window-view'); + +// --------------------------------------------------------------------------- +// Pages +// --------------------------------------------------------------------------- + +function showPage(name, opts) { + const page = PAGES[name] ? name : 'practice'; + document.querySelectorAll('.page').forEach(el => { el.hidden = el.dataset.page !== page; }); + document.querySelectorAll('.navbtn[data-page]').forEach(btn => { + const on = btn.dataset.page === page; + btn.classList.toggle('on', on); + if (on) btn.setAttribute('aria-current', 'page'); + else btn.removeAttribute('aria-current'); + }); + getEl('pageTitle').textContent = PAGES[page].title; + getEl('pageOverline').textContent = PAGES[page].overline; + closeComboMenu(); + + if (page === 'characters') { + const frame = getEl('charactersFrame'); + if (frame && !frame.getAttribute('src')) frame.setAttribute('src', frame.dataset.src); + } + if (page === 'practice' && appState.autoScrollEnabled) applyAutoScroll(); + + try { localStorage.setItem(PAGE_STORAGE_KEY, page); } catch (_) { /* ignore */ } + if (!(opts && opts.fromHash) && location.hash.slice(1) !== page) { + history.replaceState(null, '', '#' + page); + } +} + +document.addEventListener('click', (e) => { + const nav = e.target.closest('.navbtn[data-page], [data-page-link]'); + if (!nav) return; + e.preventDefault(); + showPage(nav.dataset.page || nav.dataset.pageLink); +}); +window.addEventListener('hashchange', () => showPage(location.hash.slice(1), { fromHash: true })); + +// Collapse the rail to icons (remembered per browser) +const appEl = getEl('app'); +appEl.classList.toggle('compact', readStoredFlag('ctRailCompact', false)); +getEl('railToggle')?.addEventListener('click', () => { + const compact = !appEl.classList.contains('compact'); + appEl.classList.toggle('compact', compact); + writeStoredFlag('ctRailCompact', compact); +}); + +// --------------------------------------------------------------------------- +// Toast (replaces the old status text for saves) +// --------------------------------------------------------------------------- + +let toastTimer = null; +function showToast(text) { + const el = getEl('toast'); + if (!el || !text) return; + el.textContent = text; + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.hidden = true; }, 2200); +} + +function flushPendingToast() { + if (!appState.pendingToast) return; + showToast(appState.pendingToast); + appState.pendingToast = ''; +} + +// --------------------------------------------------------------------------- +// Rail status card: Connected / Recording / Macro armed +// --------------------------------------------------------------------------- + +function renderRailStatus() { + const recording = !!getEl('transcribeModeToggle')?.checked; + const macro = !!getEl('macroModeToggle')?.checked; + let kind, title, detail; + if (!appState.connected) { + [kind, title, detail] = ['danger', 'Disconnected', 'Reconnecting to the tracker']; + } else if (recording) { + const start = (getEl('transcribeStartKey')?.value || 'f').trim().toUpperCase(); + [kind, title, detail] = ['danger', 'Recording', `${start} starts, Esc stops`]; + } else if (macro) { + const start = (getEl('macroStartKey')?.value || '').trim().toUpperCase() || 'Start key'; + const stop = (getEl('macroStopKey')?.value || '').trim().toUpperCase() || 'Esc'; + [kind, title, detail] = ['warning', 'Macro armed', `${start} starts, ${stop} stops`]; + } else { + [kind, title, detail] = ['success', 'Connected', 'Listening for your inputs']; + } + const dot = getEl('railStatusDot'); + if (dot) dot.dataset.kind = kind; + getEl('railStatusTitle').textContent = title; + getEl('railStatusDetail').textContent = detail; + getEl('railStatus').title = `${title}: ${detail}`; +} + +['transcribeStartKey', 'macroStartKey', 'macroStopKey'].forEach(id => { + getEl(id)?.addEventListener('input', renderRailStatus); +}); + +// --------------------------------------------------------------------------- +// Combo list, header picker, Combos page list, History +// --------------------------------------------------------------------------- + +function setComboList(names, active, overview) { + appState.comboNames = Array.isArray(names) ? names.slice() : []; + appState.activeCombo = active || ''; + if (Array.isArray(overview)) appState.overview = overview; + renderComboViews(); +} + +function setOverview(overview) { + if (!Array.isArray(overview)) return; + appState.overview = overview; + renderComboViews(); +} + +function teamById(id) { + return appState.wwTeams.find(t => t.id === id) || null; +} + +function overviewRow(name) { + return appState.overview.find(r => r.name === name) || null; +} + +function comboTeamName(name) { + const row = overviewRow(name); + return (row && teamById(row.team_id)?.name) || ''; +} + +function formatSeconds(ms) { + const n = Number(ms); + return Number.isFinite(n) && n > 0 ? `${(n / 1000).toFixed(2)}s` : '—'; +} + +function selectCombo(name) { + if (!name) return; + closeComboMenu(); + if (name !== appState.activeCombo) sendMessage('select_combo', { name }); +} + +function renderComboViews() { + renderPicker(); + renderComboMenu(); + renderComboList(); + renderHistory(); + renderTeamCards(); +} + +function renderPicker() { + const avatars = getEl('pickerAvatars'); + if (!avatars) return; + avatars.replaceChildren(); + const hasCombo = !!appState.activeCombo; + const slots = hasCombo ? appState.wwTeamSlots : ['', '', '']; + slots.forEach(key => avatars.appendChild(makeAvatar(key, 'avatar-sm'))); + const team = hasCombo ? teamById(appState.wwTeamId) : null; + getEl('pickerTeam').textContent = team ? team.name : (hasCombo ? 'No team' : `${appState.comboNames.length} combos`); + getEl('pickerName').textContent = appState.activeCombo || 'Select a combo'; +} + +function renderComboMenu() { + const menu = getEl('comboMenu'); + if (!menu) return; + menu.replaceChildren(); + if (appState.comboNames.length === 0) { + const empty = document.createElement('p'); + empty.className = 'muted small menu-empty'; + empty.textContent = 'No combos yet. Make one on the Combos page.'; + menu.appendChild(empty); + return; + } + appState.comboNames.forEach(name => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'menu-item' + (name === appState.activeCombo ? ' on' : ''); + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', String(name === appState.activeCombo)); + const label = document.createElement('span'); + label.textContent = name; + const team = document.createElement('span'); + team.className = 'muted small'; + team.textContent = comboTeamName(name); + item.append(label, team); + item.addEventListener('click', () => selectCombo(name)); + menu.appendChild(item); + }); +} + +function openComboMenu() { + const menu = getEl('comboMenu'); + if (!menu) return; + menu.hidden = false; + getEl('comboPicker').setAttribute('aria-expanded', 'true'); + menu.querySelector('.menu-item.on, .menu-item')?.focus(); +} + +function closeComboMenu() { + const menu = getEl('comboMenu'); + if (!menu || menu.hidden) return; + menu.hidden = true; + getEl('comboPicker')?.setAttribute('aria-expanded', 'false'); +} + +getEl('comboPicker')?.addEventListener('click', () => { + if (getEl('comboMenu').hidden) openComboMenu(); + else closeComboMenu(); +}); +document.addEventListener('click', (e) => { + if (!e.target.closest('.picker')) closeComboMenu(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeComboMenu(); +}); + +function renderComboList() { + const list = getEl('comboList'); + if (!list) return; + const q = (getEl('comboSearch')?.value || '').trim().toLowerCase(); + list.replaceChildren(); + const names = appState.comboNames.filter(n => !q || n.toLowerCase().includes(q) || comboTeamName(n).toLowerCase().includes(q)); + if (names.length === 0) { + const p = document.createElement('p'); + p.className = 'muted small menu-empty'; + p.textContent = appState.comboNames.length ? 'No combos match.' : 'No combos yet. Use + to make one.'; + list.appendChild(p); + return; + } + names.forEach(name => { + const row = overviewRow(name); + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'combo-item' + (name === appState.activeCombo ? ' on' : ''); + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', String(name === appState.activeCombo)); + const text = document.createElement('span'); + text.className = 'combo-item-text'; + const label = document.createElement('span'); + label.textContent = name; + const team = document.createElement('span'); + team.className = 'muted small'; + team.textContent = comboTeamName(name) || 'No team'; + text.append(label, team); + const steps = document.createElement('span'); + steps.className = 'combo-item-steps'; + steps.textContent = row && row.steps ? `${row.steps} steps` : ''; + item.append(text, steps); + item.addEventListener('click', () => selectCombo(name)); + list.appendChild(item); + }); +} +getEl('comboSearch')?.addEventListener('input', renderComboList); + +function renderHistory() { + const body = getEl('historyBody'); + if (!body) return; + body.replaceChildren(); + const rows = appState.overview || []; + rows.forEach(r => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'history-row' + (r.name === appState.activeCombo ? ' on' : ''); + row.title = `Practice ${r.name}`; + const cells = [ + [r.name, 'history-name'], + [`${r.success} / ${r.fail}`, ''], + [formatSeconds(r.best_ms), ''], + [formatSeconds(r.avg_ms), 'hide-small'], + [formatSeconds(r.target_ms), 'hide-small'], + ]; + cells.forEach(([text, cls]) => { + const span = document.createElement('span'); + if (cls) span.className = cls; + span.textContent = text; + row.appendChild(span); + }); + row.addEventListener('click', () => { + selectCombo(r.name); + showPage('practice'); + }); + body.appendChild(row); + }); + const note = getEl('historyNote'); + if (note) { + const attempts = rows.reduce((n, r) => n + r.success + r.fail, 0); + note.textContent = rows.length === 0 ? 'No combos saved yet.' + : attempts === 0 ? 'No attempts logged yet, so every row is empty.' + : 'Pick a combo to practice it.'; + } +} + +// --------------------------------------------------------------------------- +// "Reads as": the saved combo's steps named the way the timeline names them +// --------------------------------------------------------------------------- + +const READS_AS_PREVIEW = 12; + +function renderReadsAs() { + const wrap = getEl('readsAs'); + const items = getEl('readsAsItems'); + if (!wrap || !items) return; + items.replaceChildren(); + const steps = appState.lastTimelineSteps || []; + const label = createWwMoveLabeler(wwSlotNames()); + let slot = '1'; + const named = []; + steps.forEach(step => { + const key = wwStepKey(step); + if (WW_SLOTS.includes(key)) slot = key; + const text = label(step, slot); + if (key && text) named.push([key, text]); + }); + // Long rotations would bury the form, so show the opening moves and let the rest expand. + const shown = appState.readsAsExpanded ? named : named.slice(0, READS_AS_PREVIEW); + shown.forEach(([key, text]) => { + const chip = document.createElement('span'); + chip.className = 'reads-as-item'; + const cap = document.createElement('span'); + cap.className = 'keycap keycap-sm'; + cap.textContent = key.toUpperCase(); + chip.append(cap, document.createTextNode(text)); + items.appendChild(chip); + }); + if (named.length > READS_AS_PREVIEW) { + const more = document.createElement('button'); + more.type = 'button'; + more.className = 'btn ghost reads-as-more'; + more.textContent = appState.readsAsExpanded ? 'Show less' : `+${named.length - READS_AS_PREVIEW} more`; + more.addEventListener('click', () => { + appState.readsAsExpanded = !appState.readsAsExpanded; + renderReadsAs(); + }); + items.appendChild(more); + } + wrap.hidden = named.length === 0; +} + +// --------------------------------------------------------------------------- +// Settings: overlay URLs and data actions +// --------------------------------------------------------------------------- + +function keysOverlayUrl() { + return window.location.origin + '/keys.html'; +} + +getEl('timelineOverlayUrl').textContent = getTimelineUrl().replace(/^https?:\/\//, '').replace(/#.*$/, ''); +getEl('keysOverlayUrl').textContent = keysOverlayUrl().replace(/^https?:\/\//, ''); +getEl('keyOverlayLink')?.setAttribute('target', '_blank'); + +function copyText(text, what) { + const done = () => showToast(`Copied the ${what} URL`); + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(done, () => showToast(text)); + } else { + showToast(text); + } +} +getEl('copyOverlayUrlBtn')?.addEventListener('click', () => copyText(getTimelineUrl().replace(/#.*$/, ''), 'timeline overlay')); +getEl('copyKeysUrlBtn')?.addEventListener('click', () => copyText(keysOverlayUrl(), 'key overlay')); + +// Reload combos.json from disk (same folder as the app / exe) +getEl('loadJsonBtn')?.addEventListener('click', () => { + if (!window.confirm('Reload all combos and settings from combos.json? Unsaved changes in the editor will be lost.')) { + return; + } + sendMessage('load_combos_from_json', {}); +}); + +// Clear ALL history (every combo) +const clearAllBtn = getEl('clearAllBtn'); +if (clearAllBtn) { + attachTwoClickConfirm(clearAllBtn, { + confirmText: 'Click again to wipe every combo', + onConfirm: () => sendMessage('clear_history_all'), + }); +} + +// --------------------------------------------------------------------------- +// Backend messages, batched per animation frame (keeps UI smooth with lots of hits) +// --------------------------------------------------------------------------- + +const MESSAGE_HANDLERS = { + init: (msg) => { + initializeUI(msg); + renderComboViews(); + renderReadsAs(); + flushPendingToast(); + }, + combo_list: (msg) => setComboList(msg.combos, msg.active, msg.overview), + combo_data: (msg) => { + setEditorFields(msg); + renderPicker(); + }, + min_time: (msg) => updateMinTime(msg.text), + difficulty_update: (msg) => { + updateDifficulty(msg.text); + setDifficultyColor(getEl('difficultyDisplay'), msg.value); + }, + user_difficulty_update: (msg) => { + updateUserDifficulty(msg.text); + setDifficultyColor(getEl('userDifficultyDisplay'), msg.value); + }, + apm_update: (msg) => updateAPM(msg.text), + apm_max_update: (msg) => updateAPMMax(msg.text), + hold_begin: (msg) => startHoldAnimation(msg.required_ms), + hold_end: () => stopHoldAnimation(), + wait_begin: (msg) => startWaitAnimation(msg.required_ms), + wait_end: () => stopWaitAnimation(), + spam_begin: (msg) => startSpamAnimation(msg.required_ms), + spam_end: () => stopSpamAnimation(), + hit: (msg) => addResultRow(msg), + combo_dropped: (msg) => { + stopWaitAnimation(); + stopSpamAnimation(); + stopHoldAnimation(); + updateStatus(msg.input, msg.color || 'fail'); + addResultRow(msg); + }, + clear_results: () => clearAttemptLog(), + status: (msg) => { + updateStatus(msg.text, msg.color); + if (msg.color === 'fail') { + appState.pendingToast = ''; + if (!getEl('app').querySelector('.page[data-page="practice"]:not([hidden])')) showToast(msg.text); + } + }, + alert_notice: (msg) => { + const t = (msg.text || '').toString(); + if (t) window.alert(t); + }, + stat_update: (msg) => { + updateStats(msg.stats); + setOverview(msg.overview); + }, + attempt_start: (msg) => addAttemptSeparator(msg.name, msg.attempt), + timeline_update: (msg) => { + updateTimeline(msg.steps, { focusLatest: !!msg.focus_latest }); + if (!isTimelineView) renderReadsAs(); + }, + fail_update: (msg) => { + appState.lastFailByStep = msg.fail_by_step || {}; + refreshTimelineIfLoaded(); + }, + transcription_result: (msg) => { + const inputsEl = getEl('comboInputs'); + if (inputsEl) { + inputsEl.value = msg.inputs || ''; + if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); + } + }, +}; + +function handleMessage(msg) { + const fn = MESSAGE_HANDLERS[msg.type]; + if (fn) fn(msg); +} + +function processBatch() { + if (appState.batchQueue.length === 0) { + appState.isProcessingBatch = false; + return; + } + appState.isProcessingBatch = true; + + requestAnimationFrame(() => { + const batch = appState.batchQueue.splice(0, appState.batchQueue.length); + batch.forEach(handleMessage); + appState.isProcessingBatch = false; + if (appState.batchQueue.length > 0) processBatch(); + }); +} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +let startPage = location.hash.slice(1); +if (!PAGES[startPage]) { + try { startPage = localStorage.getItem(PAGE_STORAGE_KEY) || 'practice'; } catch (_) { startPage = 'practice'; } +} +showPage(isTimelineView ? 'practice' : startPage, { fromHash: isTimelineView }); +renderComboViews(); +renderRailStatus(); + +tracker = connectTracker({ + onOpen: () => { + console.log('Connected to WuWa Combo Tracker backend'); + appState.connected = true; + updateStatus('Status: Ready', 'neutral'); + renderRailStatus(); + }, + onClose: (retryMs) => { + console.warn(`Backend connection lost; retrying in ${retryMs}ms`); + appState.connected = false; + updateStatus('Backend disconnected. Reconnecting…', 'fail'); + renderRailStatus(); + }, + onMessage: (msg) => { + appState.batchQueue.push(msg); + if (!appState.isProcessingBatch) processBatch(); + }, +}); diff --git a/static/app/teams.js b/static/app/teams.js new file mode 100644 index 0000000..9c566d5 --- /dev/null +++ b/static/app/teams.js @@ -0,0 +1,602 @@ +// Teams page: team cards, the team editor, the character icon library, and the combo's Team dropdown. + +// ComboTracker is Wuthering Waves only; every combo uses the WW team/character features. +function normalizeTargetGame(_v) { + return 'wuthering_waves'; +} + +function ensureWwAbilityShape(obj) { + const out = { "1": {}, "2": {}, "3": {} }; + if (!obj || typeof obj !== 'object') return out; + ['1', '2', '3'].forEach(c => { + if (obj[c] && typeof obj[c] === 'object') { + ['e', 'q', 'r'].forEach(a => { + const url = (obj[c][a] || '').toString().trim(); + if (url) out[c][a] = url; + }); + } + }); + return out; +} + +function ensureWwSlotShape(obj) { + const out = { "1": "", "2": "", "3": "" }; + if (!obj || typeof obj !== 'object') return out; + ['1', '2', '3'].forEach(k => { + const url = (obj[k] || '').toString().trim(); + if (url) out[k] = url; + }); + return out; +} + +function syncGameUIVisibility() { + const isWW = appState.targetGame === 'wuthering_waves'; + + const wwPanels = getEl('wwPanels'); + if (wwPanels) wwPanels.classList.toggle('hidden', !isWW); + + const keyDetails = getEl('keyImagesDetails'); + if (keyDetails) keyDetails.classList.toggle('hidden', isWW); + + if (isWW) { + renderWwTeamEditor(); + renderWwCharacterEditor(); + renderWwDashPreview(); + } + + // Re-render key images editor (generic mode only). + renderKeyImagesEditor(); +} + +// ----- WW helper: image preview ----- +function wwSetPreview(el, val) { + const v = (val || '').toString().trim(); + if (!v) { el.innerHTML = ''; el.style.display = 'none'; return; } + el.style.display = 'flex'; + el.style.alignItems = 'center'; + el.style.justifyContent = 'center'; + if (/^https?:\/\//i.test(v)) { + el.innerHTML = ``; + } else { + el.innerHTML = `${escapeHtml(v)}`; + } +} + +function wwSetSlotPortrait(el, char) { + el.replaceChildren(); + const value = (char?.swap_image || '').toString().trim(); + el.classList.toggle('ww-slot-portrait-empty', !value); + el.title = char?.name ? `${char.name} portrait` : 'Empty character slot'; + + if (!value) return; + if (/^https?:\/\//i.test(value)) { + const img = document.createElement('img'); + img.src = value; + img.alt = char?.name ? `${char.name} portrait` : 'Character portrait'; + img.loading = 'lazy'; + img.referrerPolicy = 'no-referrer'; + img.draggable = false; + el.appendChild(img); + } else { + const emoji = document.createElement('span'); + emoji.textContent = value; + el.appendChild(emoji); + } +} + +/** Team display names that still reference this character (by name_key, case-insensitive). */ +function wwTeamNamesReferencingCharacter(nameKey) { + const key = (nameKey || '').toString().trim().toLowerCase(); + if (!key) return []; + const names = []; + (appState.wwTeams || []).forEach(t => { + if (!t || typeof t !== 'object') return; + const slots = [t.slot1, t.slot2, t.slot3].map(s => (s || '').toString().trim().toLowerCase()); + if (slots.includes(key)) names.push((t.name || t.id || '').toString() || 'Team'); + }); + return names; +} + +// ----- WW Dash preview ----- +function renderWwDashPreview() { + const input = getEl('wwDashImageInput'); + const preview = getEl('wwDashPreview'); + if (input && preview) { + input.value = (appState.wwDashImage || '').toString(); + wwSetPreview(preview, input.value); + } +} + +// ----- Teams ----- +// The Combos page's Team dropdown picks the team a combo uses (appState.wwTeamId / wwTeamSlots). +// The Teams page edits teams on their own (appState.teamEdit), so browsing teams never changes the combo. +function blankTeamEdit() { + return { id: '', name: '', slots: ['', '', ''], syncedSnap: '', pendingName: '' }; +} + +function wwCharByKey(key) { + const k = (key || '').toString().trim().toLowerCase(); + return k ? appState.wwCharacters[k] || null : null; +} + +/** Round portrait: the character's swap image when it is a URL, else their initials. */ +function makeAvatar(charKey, extraClass) { + const el = document.createElement('span'); + el.className = 'avatar' + (extraClass ? ' ' + extraClass : ''); + const ch = wwCharByKey(charKey); + const name = ch?.name || (charKey || '').toString(); + el.title = name || 'Empty slot'; + const img = (ch?.swap_image || '').toString().trim(); + if (/^https?:\/\//i.test(img)) { + const pic = document.createElement('img'); + pic.src = img; + pic.alt = ''; + pic.referrerPolicy = 'no-referrer'; + pic.onerror = () => { pic.remove(); el.textContent = name.slice(0, 2).toUpperCase(); }; + el.appendChild(pic); + } else if (name) { + el.textContent = name.slice(0, 2).toUpperCase(); + } else { + el.classList.add('avatar-empty'); + } + return el; +} + +function renderWwTeamEditor() { + renderTeamSelect(); + syncTeamEditFromServer(); + renderTeamCards(); + renderTeamSlotsEditor(); +} + +function renderTeamSelect() { + const teamSelect = getEl('wwTeamSelect'); + if (!teamSelect) return; + teamSelect.innerHTML = ''; + appState.wwTeams.forEach(t => { + const opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.name; + teamSelect.appendChild(opt); + }); + teamSelect.value = appState.wwTeamId || ''; +} + +/** Pick up saved changes for the team being edited; unsaved edits survive unrelated refreshes. */ +function syncTeamEditFromServer() { + const te = appState.teamEdit; + if (!te.id && te.pendingName) { + const made = appState.wwTeams.find(t => (t.name || '') === te.pendingName); + if (made) te.id = made.id; + } + if (!te.id) return; + const t = appState.wwTeams.find(x => x.id === te.id); + if (!t) { appState.teamEdit = blankTeamEdit(); return; } + const snap = JSON.stringify([t.name, t.slot1, t.slot2, t.slot3]); + if (snap === te.syncedSnap) return; + Object.assign(te, { name: t.name || '', slots: [t.slot1 || '', t.slot2 || '', t.slot3 || ''], syncedSnap: snap, pendingName: '' }); +} + +function editTeam(teamId) { + appState.teamEdit = blankTeamEdit(); + appState.teamEdit.id = teamId || ''; + syncTeamEditFromServer(); + renderTeamCards(); + renderTeamSlotsEditor(); +} + +function renderTeamCards() { + const wrap = getEl('teamCards'); + if (!wrap) return; + wrap.replaceChildren(); + const counts = {}; + (appState.overview || []).forEach(r => { if (r.team_id) counts[r.team_id] = (counts[r.team_id] || 0) + 1; }); + if (appState.wwTeams.length === 0) { + const p = document.createElement('p'); + p.className = 'muted'; + p.textContent = 'No teams yet. Use New team to make one.'; + wrap.appendChild(p); + return; + } + appState.wwTeams.forEach(t => { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'card team-card' + (t.id === appState.teamEdit.id ? ' on' : ''); + card.title = `Edit ${t.name}`; + card.addEventListener('click', () => editTeam(t.id)); + + const head = document.createElement('div'); + head.className = 'team-card-head'; + const h = document.createElement('span'); + h.className = 'team-card-name'; + h.textContent = t.name; + const n = counts[t.id] || 0; + const c = document.createElement('span'); + c.className = 'muted small'; + c.textContent = n === 1 ? '1 combo' : `${n} combos`; + head.append(h, c); + card.appendChild(head); + + [t.slot1, t.slot2, t.slot3].forEach((key, i) => { + const row = document.createElement('div'); + row.className = 'team-slot'; + const cap = document.createElement('span'); + cap.className = 'keycap'; + cap.textContent = String(i + 1); + const name = document.createElement('span'); + name.textContent = wwCharByKey(key)?.name || (key ? key : 'Empty'); + if (!key) name.className = 'muted'; + row.append(cap, makeAvatar(key), name); + card.appendChild(row); + }); + wrap.appendChild(card); + }); +} + +function renderTeamSlotsEditor() { + const te = appState.teamEdit; + const title = getEl('teamEditorTitle'); + if (title) title.textContent = te.id ? `Edit ${te.name || 'team'}` : 'New team'; + const nameEl = getEl('wwTeamName'); + if (nameEl && document.activeElement !== nameEl) nameEl.value = te.name || ''; + const delBtn = getEl('deleteTeamBtn'); + if (delBtn) delBtn.disabled = !te.id; + + const slotsContainer = getEl('wwTeamSlots'); + if (!slotsContainer) return; + slotsContainer.innerHTML = ''; + + const charOptions = () => { + const empty = ''; + const chars = Object.values(appState.wwCharacters) + .filter(c => c && c.name) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')); + return empty + chars.map(c => ``).join(''); + }; + + let dragSrcIdx = null; + + te.slots.forEach((charKey, idx) => { + const row = document.createElement('div'); + row.className = 'ww-slot-row'; + row.draggable = true; + row.dataset.idx = idx; + + const handle = document.createElement('span'); + handle.className = 'ww-slot-handle'; + handle.textContent = '⠿'; + handle.title = 'Drag to reorder'; + + const label = document.createElement('span'); + label.className = 'keycap'; + label.textContent = String(idx + 1); + label.title = `Slot ${idx + 1}`; + + const portrait = document.createElement('div'); + portrait.className = 'ww-slot-portrait'; + wwSetSlotPortrait(portrait, charKey ? appState.wwCharacters[charKey] : null); + + const sel = document.createElement('select'); + sel.className = 'ww-slot-char-select input'; + sel.setAttribute('aria-label', `Slot ${idx + 1} character`); + sel.innerHTML = charOptions(); + sel.value = charKey || ''; + + sel.addEventListener('change', () => { + te.slots[idx] = sel.value; + wwSetSlotPortrait(portrait, sel.value ? appState.wwCharacters[sel.value] : null); + }); + + row.appendChild(handle); + row.appendChild(label); + row.appendChild(portrait); + row.appendChild(sel); + slotsContainer.appendChild(row); + + row.addEventListener('dragstart', e => { + dragSrcIdx = idx; + e.dataTransfer.effectAllowed = 'move'; + setTimeout(() => row.classList.add('ww-drag-active'), 0); + }); + row.addEventListener('dragend', () => { + dragSrcIdx = null; + row.classList.remove('ww-drag-active'); + slotsContainer.querySelectorAll('.ww-slot-row').forEach(r => r.classList.remove('ww-drag-over')); + }); + row.addEventListener('dragover', e => { + if (dragSrcIdx !== null && dragSrcIdx !== idx) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + slotsContainer.querySelectorAll('.ww-slot-row').forEach(r => r.classList.remove('ww-drag-over')); + row.classList.add('ww-drag-over'); + } + }); + row.addEventListener('dragleave', e => { + if (!row.contains(e.relatedTarget)) row.classList.remove('ww-drag-over'); + }); + row.addEventListener('drop', e => { + e.preventDefault(); + const src = dragSrcIdx; + if (src !== null && src !== idx) { + const tmp = te.slots[src]; + te.slots[src] = te.slots[idx]; + te.slots[idx] = tmp; + renderTeamSlotsEditor(); + } + }); + }); +} + +// Resolve current team slots → appState.wwSwapImages / wwLmbImages / wwAbilityImages for timeline +function _resolveTeamImagesToState() { + const swap = { "1": "", "2": "", "3": "" }; + const lmb = { "1": "", "2": "", "3": "" }; + const ability = { "1": {}, "2": {}, "3": {} }; + appState.wwTeamSlots.forEach((charKey, idx) => { + const sk = String(idx + 1); + const char = charKey ? appState.wwCharacters[charKey] : null; + if (!char) return; + if (char.swap_image) swap[sk] = char.swap_image; + if (char.lmb_image) lmb[sk] = char.lmb_image; + if (char.ability_images && typeof char.ability_images === 'object') { + ability[sk] = { ...char.ability_images }; + } + }); + appState.wwSwapImages = swap; + appState.wwLmbImages = lmb; + appState.wwAbilityImages = ability; +} + +// ----- WW Character editor ----- +function _buildCharRow(labelText, inputValue, onInput) { + const row = document.createElement('div'); + row.className = 'ww-ability-row'; + const label = document.createElement('span'); + label.className = 'ww-ability-label'; + label.textContent = labelText; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'https://... or emoji'; + input.value = inputValue || ''; + const preview = document.createElement('div'); + preview.className = 'ww-ability-preview'; + wwSetPreview(preview, input.value); + input.addEventListener('input', () => { + onInput(input.value.trim()); + wwSetPreview(preview, input.value); + }); + row.appendChild(label); + row.appendChild(input); + row.appendChild(preview); + return row; +} + +function renderWwCharacterPicker(chars) { + const picker = getEl('wwCharPicker'); + if (!picker) return; + picker.replaceChildren(); + + const selectedKey = appState.wwCurrentChar || ''; + const selectedChar = selectedKey ? appState.wwCharacters[selectedKey] : null; + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'ww-char-picker-trigger'; + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-expanded', 'false'); + + const triggerPortrait = document.createElement('span'); + triggerPortrait.className = 'ww-char-picker-portrait'; + wwSetSlotPortrait(triggerPortrait, selectedChar); + const triggerLabel = document.createElement('span'); + triggerLabel.className = 'ww-char-picker-label'; + triggerLabel.textContent = selectedChar?.name || '— New Character —'; + const chevron = document.createElement('span'); + chevron.className = 'ww-char-picker-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + trigger.append(triggerPortrait, triggerLabel, chevron); + + const menu = document.createElement('div'); + menu.className = 'ww-char-picker-menu'; + menu.setAttribute('role', 'listbox'); + menu.hidden = true; + + const choose = (key) => { + appState.wwCurrentChar = key || null; + renderWwCharacterEditor(); + }; + const addOption = (char) => { + const key = char ? (char.name_key || char.name.toLowerCase()) : ''; + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'ww-char-picker-option'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', String(key === selectedKey)); + + const portrait = document.createElement('span'); + portrait.className = 'ww-char-picker-portrait'; + wwSetSlotPortrait(portrait, char); + const label = document.createElement('span'); + label.textContent = char?.name || '— New Character —'; + option.append(portrait, label); + option.addEventListener('click', () => choose(key)); + menu.appendChild(option); + }; + + addOption(null); + chars.forEach(addOption); + trigger.addEventListener('click', () => { + const isOpen = menu.hidden; + menu.hidden = !isOpen; + trigger.setAttribute('aria-expanded', String(isOpen)); + }); + trigger.addEventListener('keydown', e => { + if (e.key === 'Escape') { + menu.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); + } + }); + + picker.append(trigger, menu); +} + +function renderWwCharacterEditor() { + const chars = Object.values(appState.wwCharacters) + .filter(c => c && c.name) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')); + renderWwCharacterPicker(chars); + + const container = getEl('wwCharEditor'); + if (!container) return; + container.innerHTML = ''; + + const loaded = appState.wwCurrentChar ? appState.wwCharacters[appState.wwCurrentChar] : null; + + // Name row + const nameRow = document.createElement('div'); + nameRow.className = 'ww-char-name-row'; + const nameLabel = document.createElement('span'); + nameLabel.className = 'ww-ability-label'; + nameLabel.textContent = 'Name :'; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.id = 'wwCharNameInput'; + nameInput.placeholder = 'Character name'; + nameInput.value = (loaded && loaded.name) ? loaded.name : ''; + nameRow.appendChild(nameLabel); + nameRow.appendChild(nameInput); + container.appendChild(nameRow); + + // Character (slot swap) key icon, LMB, Q, E, R rows + let swapVal = (loaded && loaded.swap_image) || ''; + let lmbVal = (loaded && loaded.lmb_image) || ''; + let abilVals = { q: '', e: '', r: '' }; + if (loaded && loaded.ability_images) { + abilVals.q = loaded.ability_images.q || ''; + abilVals.e = loaded.ability_images.e || ''; + abilVals.r = loaded.ability_images.r || ''; + } + + container.appendChild(_buildCharRow('Character :', swapVal, v => { swapVal = v; })); + container.appendChild(_buildCharRow('LMB :', lmbVal, v => { lmbVal = v; })); + container.appendChild(_buildCharRow('Q :', abilVals.q, v => { abilVals.q = v; })); + container.appendChild(_buildCharRow('E :', abilVals.e, v => { abilVals.e = v; })); + container.appendChild(_buildCharRow('R :', abilVals.r, v => { abilVals.r = v; })); + + // Save & Delete buttons + const btnRow = document.createElement('div'); + btnRow.className = 'ww-char-btn-row'; + + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.textContent = 'Save'; + saveBtn.addEventListener('click', () => { + const name = (nameInput.value || '').trim(); + if (!name) { updateStatus('Please enter a character name.', 'fail'); return; } + const nameKey = name.toLowerCase(); + const isNew = appState.wwCurrentChar === null; + const overwritingDifferent = !isNew && nameKey !== appState.wwCurrentChar && nameKey in appState.wwCharacters; + const overwritingExisting = isNew && nameKey in appState.wwCharacters; + const doSave = () => sendMessage('save_character', { + name, + swap_image: swapVal, + lmb_image: lmbVal, + ability_images: { q: abilVals.q, e: abilVals.e, r: abilVals.r }, + }); + if (overwritingDifferent || overwritingExisting) { + if (!confirm(`"${name}" already exists. Overwrite?`)) return; + } + doSave(); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.textContent = 'Delete'; + deleteBtn.className = 'danger'; + deleteBtn.disabled = !loaded; + deleteBtn.addEventListener('click', () => { + if (!appState.wwCurrentChar) return; + const charName = (loaded && loaded.name) || appState.wwCurrentChar; + const blockingTeams = wwTeamNamesReferencingCharacter(appState.wwCurrentChar); + if (blockingTeams.length) { + alert(`Remove from these teams first: ${blockingTeams.join(', ')}`); + return; + } + if (!confirm(`Delete character "${charName}"?`)) return; + sendMessage('delete_character', { name: charName }); + }); + + btnRow.appendChild(saveBtn); + btnRow.appendChild(deleteBtn); + container.appendChild(btnRow); +} + +// WW: one-click character sync from wuthering.gg +const wwSyncCharNameEl = getEl('wwSyncCharName'); +function syncCharacterFromWeb() { + const name = (wwSyncCharNameEl?.value || '').toString().trim(); + if (!name) { updateStatus('Type a character name first.', 'fail'); return; } + sendMessage('sync_character', { name }); + wwSyncCharNameEl.value = ''; +} +getEl('wwSyncCharBtn')?.addEventListener('click', syncCharacterFromWeb); +wwSyncCharNameEl?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); syncCharacterFromWeb(); } +}); +getEl('wwSyncAllBtn')?.addEventListener('click', () => sendMessage('sync_all_characters')); + +// WW: team select dropdown +document.addEventListener('change', e => { + if (e.target && e.target.id === 'wwTeamSelect') { + appState.wwTeamId = (e.target.value || '').toString(); + sendMessage('select_team', { team_id: appState.wwTeamId, target_game: appState.targetGame }); + } +}); + +// Teams page: save / new / delete act on the team being edited +getEl('wwTeamName')?.addEventListener('input', (e) => { appState.teamEdit.name = e.target.value; }); + +getEl('saveTeamBtn')?.addEventListener('click', () => { + const te = appState.teamEdit; + const name = (getEl('wwTeamName')?.value || '').toString().trim(); + if (!name) { showToast('Give the team a name first.'); return; } + te.name = name; + if (!te.id) te.pendingName = name; + appState.pendingToast = `Saved ${name}`; + sendMessage('save_team', { + team_id: te.id || '', + team_name: name, + slot1: te.slots[0] || '', + slot2: te.slots[1] || '', + slot3: te.slots[2] || '', + }); +}); + +getEl('newTeamBtn')?.addEventListener('click', () => { + appState.teamEdit = blankTeamEdit(); + renderTeamCards(); + renderTeamSlotsEditor(); + getEl('wwTeamName')?.focus(); +}); + +const deleteTeamBtn = getEl('deleteTeamBtn'); +if (deleteTeamBtn) { + attachTwoClickConfirm(deleteTeamBtn, { + confirmText: 'Click again to delete', + onConfirm: () => { + const id = appState.teamEdit.id; + if (!id) return; + sendMessage('delete_team', { team_id: id }); + } + }); +} + +// WW: dash input +document.addEventListener('input', e => { + if (e.target && e.target.id === 'wwDashImageInput') { + appState.wwDashImage = e.target.value.trim(); + const preview = getEl('wwDashPreview'); + if (preview) wwSetPreview(preview, e.target.value); + sendMessage('update_ww_dash', { dash_image: appState.wwDashImage }); + refreshTimelineIfLoaded(); + } +}); diff --git a/static/app/timeline.js b/static/app/timeline.js new file mode 100644 index 0000000..a90c2ec --- /dev/null +++ b/static/app/timeline.js @@ -0,0 +1,1325 @@ +// Timeline-only view for OBS (Browser Source or Window Capture) +if (new URLSearchParams(window.location.search).get('view') === 'timeline') { + document.title = 'WuWa Combo Tracker – Timeline'; + document.body.classList.add('timeline-window-view'); +} + +function getTimelineUrl() { + const path = window.location.pathname || '/'; + return window.location.origin + path + (path.includes('?') ? '&' : '?') + 'view=timeline'; +} + +// Hold / Wait animations (global UI state, not per-combo) +let holdAnim = { active: false, requiredMs: 0, startedAt: 0 }; +let holdRafId = null; + +let waitAnim = { active: false, requiredMs: 0, startedAt: 0 }; +let waitRafId = null; + +let spamAnim = { active: false, requiredMs: 0, startedAt: 0 }; +let spamRafId = null; + +function stopHoldAnimation() { + holdAnim.active = false; + if (holdRafId !== null) { + cancelAnimationFrame(holdRafId); + holdRafId = null; + } +} + +function tickHoldAnimation() { + if (!holdAnim.active) { + holdRafId = null; + return; + } + const stepEl = document.querySelector('.step.hold.active, .step.hold-with-body.active, .step.hold-with_body.active'); + if (!stepEl) { + // Timeline might be re-rendering; try again next frame. + holdRafId = requestAnimationFrame(tickHoldAnimation); + return; + } + + const elapsed = performance.now() - holdAnim.startedAt; + const req = Math.max(1, holdAnim.requiredMs || 1); + const pct = Math.max(0, Math.min(100, (elapsed / req) * 100)); + stepEl.style.setProperty('--hold-pct', `${pct}%`); + updateStatus(`Holding... ${Math.round(pct)}%`, 'recording'); + + if (pct >= 100) { + holdRafId = null; + return; + } + holdRafId = requestAnimationFrame(tickHoldAnimation); +} + +function startHoldAnimation(requiredMs) { + stopHoldAnimation(); + holdAnim.active = true; + holdAnim.requiredMs = Math.max(1, Number(requiredMs) || 1); + holdAnim.startedAt = performance.now(); + holdRafId = requestAnimationFrame(tickHoldAnimation); +} + +function stopWaitAnimation() { + waitAnim.active = false; + if (waitRafId !== null) { + cancelAnimationFrame(waitRafId); + waitRafId = null; + } +} + +function tickWaitAnimation() { + if (!waitAnim.active) { + waitRafId = null; + return; + } + // Select active wait steps, including hold-body wait chips. + const stepEl = document.querySelector( + '.step.wait.active, .step.press-wait.active, ' + + '.hold-body-chip.wait.active, .hold-body-chip.press-wait.active .hold-body-inline-wait' + ); + if (!stepEl) { + waitRafId = requestAnimationFrame(tickWaitAnimation); + return; + } + + const elapsed = performance.now() - waitAnim.startedAt; + const req = Math.max(1, Number(waitAnim.requiredMs) || 1); + const pct = Math.max(0, Math.min(100, (elapsed / req) * 100)); + const progressProperty = stepEl.classList.contains('hold-body-chip') + || stepEl.classList.contains('hold-body-inline-wait') + ? '--chip-wait-pct' + : '--wait-pct'; + stepEl.style.setProperty(progressProperty, `${pct}%`); + updateStatus(`Waiting... ${Math.round(pct)}%`, 'wait'); + + if (pct >= 100) { + waitRafId = null; + return; + } + waitRafId = requestAnimationFrame(tickWaitAnimation); +} + +function startWaitAnimation(requiredMs) { + stopWaitAnimation(); + waitAnim.active = true; + waitAnim.requiredMs = Math.max(1, Number(requiredMs) || 1); + waitAnim.startedAt = performance.now(); + waitRafId = requestAnimationFrame(tickWaitAnimation); +} + +function stopSpamAnimation() { + spamAnim.active = false; + if (spamRafId !== null) { + cancelAnimationFrame(spamRafId); + spamRafId = null; + } +} + +function tickSpamAnimation() { + if (!spamAnim.active) { + spamRafId = null; + return; + } + const stepEl = document.querySelector( + '.step.spam.active, .hold-body-chip.spam.active .hold-body-inline-wait' + ); + if (!stepEl) { + spamRafId = requestAnimationFrame(tickSpamAnimation); + return; + } + + const elapsed = performance.now() - spamAnim.startedAt; + const req = Math.max(1, Number(spamAnim.requiredMs) || 1); + const pct = Math.max(0, Math.min(100, (elapsed / req) * 100)); + const progressProperty = stepEl.classList.contains('hold-body-inline-wait') + ? '--chip-wait-pct' + : '--wait-pct'; + stepEl.style.setProperty(progressProperty, `${pct}%`); + updateStatus(`Spamming... ${Math.round(pct)}%`, 'wait'); + + if (pct >= 100) { + spamRafId = null; + return; + } + spamRafId = requestAnimationFrame(tickSpamAnimation); +} + +function startSpamAnimation(requiredMs) { + stopSpamAnimation(); + spamAnim.active = true; + spamAnim.requiredMs = Math.max(1, Number(requiredMs) || 1); + spamAnim.startedAt = performance.now(); + spamRafId = requestAnimationFrame(tickSpamAnimation); +} + +// --------------------------------------------------------------------------- +// Step inline editing helpers +// --------------------------------------------------------------------------- + +/** + * JS port of the Python split_inputs: splits a comma-separated inputs string + * into top-level tokens, respecting nested (, {, [ delimiters. + */ +function splitInputsTokens(str) { + const out = []; + let buf = ''; + let paren = 0, brace = 0, bracket = 0; + for (const ch of (str || '')) { + if (ch === '(') paren++; + else if (ch === ')') paren = Math.max(0, paren - 1); + else if (ch === '{') brace++; + else if (ch === '}') brace = Math.max(0, brace - 1); + else if (ch === '[') bracket++; + else if (ch === ']') bracket = Math.max(0, bracket - 1); + + if (ch === ',' && paren === 0 && brace === 0 && bracket === 0) { + const tok = buf.trim(); + if (tok) out.push(tok); + buf = ''; + continue; + } + buf += ch; + } + const last = buf.trim(); + if (last) out.push(last); + return out; +} + +/** + * Format a duration in milliseconds back to the shortest token string. + * Uses "s" suffix if divisible cleanly, otherwise "ms". + */ +function formatDurationToken(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return `${ms}ms`; + if (n % 1000 === 0) return `${n / 1000}s`; + if (n % 100 === 0) return `${(n / 1000).toFixed(1)}s`; + if (n % 10 === 0) return `${(n / 1000).toFixed(2)}s`; + return `${Math.round(n)}ms`; +} + +/** + * Parse a user-entered duration string (e.g. "0.23s", "230ms", "230") to ms. + * Returns null if unparseable. + */ +function parseDurationToMs(raw) { + let s = (raw || '').trim().toLowerCase(); + if (!s) return null; + // Strip trailing UI markers (e.g. hold-with-body status checkmark) + s = s.replace(/\s*✓\s*$/u, '').trim(); + if (s.endsWith('ms')) { + const v = parseFloat(s.slice(0, -2)); + return Number.isFinite(v) && v > 0 ? Math.round(v) : null; + } + if (s.endsWith('s')) { + const v = parseFloat(s.slice(0, -1)); + return Number.isFinite(v) && v > 0 ? Math.round(v * 1000) : null; + } + const v = parseFloat(s); + if (Number.isFinite(v) && v > 0) { + // Treat as seconds if it looks fractional, ms otherwise. + return s.includes('.') ? Math.round(v * 1000) : Math.round(v); + } + return null; +} + +/** + * Reconstruct source token(s) for a step after an inline field edit. + * Returns an array of 1 or 2 token strings, or null on invalid input. + * + * `field` is either 'key' or 'duration'. + * `newValue` is the raw string the user typed. + * `s` is the step data dict from the backend. + * `oldSourceToken` — original combo-input token when rebuilding complex holds (hold-with-body). + */ +function extractHoldWithBodyParts(oldSourceToken) { + const t = (oldSourceToken || '').trim(); + if (!t.toLowerCase().startsWith('hold(') || !t.endsWith(')')) return null; + const inner = t.slice(5, -1); + const braceIdx = inner.indexOf('{'); + if (braceIdx === -1) return null; + let head = inner.slice(0, braceIdx).replace(/,\s*$/, '').trim(); + const bodyPart = inner.slice(braceIdx).trim(); + const commaIdx = head.indexOf(','); + if (commaIdx === -1) return null; + const key = head.slice(0, commaIdx).trim(); + const durPart = head.slice(commaIdx + 1).trim(); + return { key, durPart, bodyPart }; +} + +function reconstructTokensForEdit(s, field, newValue, oldSourceToken) { + const val = (newValue || '').trim().toLowerCase(); + if (!val) return null; + + if (s.type === 'press') { + // Only 'key' editable + return [val]; + } + + if (s.type === 'press_wait') { + // Two source tokens: key token + wait:Xs token + const key = field === 'key' ? val : (s.input || '').toLowerCase(); + if (!key) return null; + const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; + if (!durMs) return null; + return [key, `wait:${formatDurationToken(durMs)}`]; + } + + if (s.type === 'hold') { + // One token: hold(key, durMs) + const key = field === 'key' ? val : (s.input || '').toLowerCase(); + if (!key) return null; + const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; + if (!durMs) return null; + return [`hold(${key}, ${formatDurationToken(durMs)})`]; + } + + if (s.type === 'spam') { + const key = field === 'key' ? val : (s.input || '').toLowerCase(); + if (!key) return null; + const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; + if (!durMs) return null; + return [`spam(${key}, ${formatDurationToken(durMs)})`]; + } + + if (s.type === 'hold_with_body') { + const parts = extractHoldWithBodyParts(oldSourceToken); + if (!parts) return null; + let key = parts.key.toLowerCase(); + let durMs = field === 'duration' ? parseDurationToMs(val) : Number(s.duration || 0); + if (field === 'key') key = val; + if (field === 'duration') { + if (!durMs || !Number.isFinite(durMs)) return null; + } else if (!durMs || !Number.isFinite(durMs)) { + durMs = parseDurationToMs(parts.durPart); + if (!durMs) return null; + } + const durTok = formatDurationToken(durMs); + return [`hold(${key}, ${durTok}, ${parts.bodyPart})`]; + } + + if (s.type === 'wait' && s.mode === 'mandatory') { + // One token: wait(key, durMs) + const key = field === 'key' ? val : (s.wait_for || '').toLowerCase(); + if (!key) return null; + const durMs = field === 'duration' ? parseDurationToMs(val) : s.duration; + if (!durMs) return null; + return [`wait(${key}, ${formatDurationToken(durMs)})`]; + } + + if (s.type === 'wait') { + // Standalone soft/hard wait: one token wait:Xs + const durMs = parseDurationToMs(val); + if (!durMs) return null; + return [`wait:${formatDurationToken(durMs)}`]; + } + + return null; +} + +/** Snapshot of the fields save_combo persists, used to restore prior state on undo. */ +function captureComboSnapshot() { + return { + name: (getEl('comboName')?.value || '').toString(), + inputs: (getEl('comboInputs')?.value || '').toString(), + enders: (getEl('comboEnders')?.value || '').toString(), + expected_time: (getEl('comboExpectedTime')?.value || '').toString(), + user_difficulty: (getEl('comboUserDifficulty')?.value || '').toString(), + step_display_mode: getEl('stepDisplayToggle')?.checked ? 'images' : 'icons', + key_images: { ...appState.keyImages }, + demo_video: (getEl('comboDemoVideo')?.value || '').toString().trim(), + target_game: appState.targetGame, + ww_team_id: appState.wwTeamId || '', + }; +} + +const EDIT_STEPS_UNDO_LIMIT = 50; + +/** Call before applying an Edit Steps mutation (delete/reorder/inline edit) so it can be undone. */ +function pushEditStepsUndoSnapshot() { + appState.editStepsUndoStack.push(captureComboSnapshot()); + if (appState.editStepsUndoStack.length > EDIT_STEPS_UNDO_LIMIT) appState.editStepsUndoStack.shift(); +} + +/** Ctrl/Cmd+Z while Edit Steps is on: restore the state captured before the last mutation. */ +function undoLastEditStep() { + const snapshot = appState.editStepsUndoStack.pop(); + if (!snapshot) return false; + sendMessage('save_combo', snapshot); + updateStatus('Undid last step edit.', 'neutral'); + return true; +} + +/** + * Commit an inline step field edit: + * 1. Locate source token(s) via runtime_source_token_indices (runtime index → source indices). + * 2. Splice new token(s) into the inputs textarea. + * 3. Send save_combo with the updated inputs string. + */ +function commitStepFieldEdit(runtimeIdx, s, field, newValue) { + const inputsEl = getEl('comboInputs'); + if (!inputsEl) return false; + + const raw = inputsEl.value || ''; + const currentTokens = splitInputsTokens(raw); + if (!currentTokens.length) return false; + + const srcMap = buildRuntimeToSourceMap(currentTokens); + if (!srcMap || runtimeIdx >= srcMap.length) return false; + + const srcIndices = srcMap[runtimeIdx]; + if (!srcIndices || srcIndices.length === 0) return false; + + const minSrc = Math.min(...srcIndices); + const oldSourceToken = currentTokens[minSrc]; + const newTokens = reconstructTokensForEdit(s, field, newValue, oldSourceToken); + if (!newTokens) return false; + + // Splice: replace source token(s) at srcIndices with newTokens. + const result = []; + let i = 0; + while (i < currentTokens.length) { + if (srcIndices.includes(i)) { + if (i === minSrc) { + newTokens.forEach(t => result.push(t)); + } + // skip the rest of the source group + } else { + result.push(currentTokens[i]); + } + i++; + } + if (result.length === 0) return false; + + const newInputs = result.join(', '); + pushEditStepsUndoSnapshot(); + inputsEl.value = newInputs; + if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); + + // Save via the same path as the Save/Update button. + readKeyImagesFromUI(); + const toggle = getEl('stepDisplayToggle'); + sendMessage('save_combo', { + name: (getEl('comboName')?.value || '').toString(), + inputs: newInputs, + enders: (getEl('comboEnders')?.value || '').toString(), + expected_time: (getEl('comboExpectedTime')?.value || '').toString(), + user_difficulty: (getEl('comboUserDifficulty')?.value || '').toString(), + step_display_mode: toggle?.checked ? 'images' : 'icons', + key_images: appState.keyImages, + demo_video: (getEl('comboDemoVideo')?.value || '').toString().trim(), + target_game: appState.targetGame, + ww_team_id: appState.wwTeamId || '', + }); + return true; +} + +/** + * JS port of runtime_source_token_indices_from_tokens from parser.py. + * Returns an array where entry[runtimeIdx] = [srcTokenIdx, ...]. + */ +function buildRuntimeToSourceMap(tokens) { + const srcMap = []; + let i = 0; + while (i < tokens.length) { + const tok = tokens[i].trim().toLowerCase(); + if (!tok) { i++; continue; } + + // press + following soft/hard wait -> one runtime SequenceNode (press_wait tile) + if (!tok.startsWith('wait') && !tok.startsWith('hold(') && !tok.startsWith('spam(') && !tok.startsWith('[') && !tok.startsWith('{')) { + // Could be a plain press followed by wait:Xs + if (i + 1 < tokens.length) { + const nxt = tokens[i + 1].trim().toLowerCase(); + if (nxt.startsWith('wait:')) { + srcMap.push([i, i + 1]); + i += 2; + continue; + } + } + } + + // wait(key, t) -> two runtime steps (press + mandatory wait) from the same source token + if (tok.startsWith('wait(') && tok.endsWith(')')) { + srcMap.push([i]); + srcMap.push([i]); + i++; + continue; + } + + // hold(key, dur, {body}) -> one runtime step (matches parser HoldWithBodyNode). + // hold(key, dur, total_ms) anim-lock -> two runtime steps from the same source token. + if (tok.startsWith('hold(') && tok.endsWith(')')) { + const raw = tokens[i].trim(); + const inner = raw.slice(5, -1); + if (inner.indexOf('{') !== -1) { + srcMap.push([i]); + i++; + continue; + } + const parts = inner.split(',').map(p => p.trim()); + if (parts.length >= 3) { + srcMap.push([i]); + srcMap.push([i]); + i++; + continue; + } + } + + srcMap.push([i]); + i++; + } + return srcMap; +} + +/** + * Make a span inline-editable on double-click when edit mode is active. + * `s` = step dict, `field` = 'key' | 'duration', `runtimeIdx` = first runtime index. + */ +function attachInlineEdit(span, s, field, runtimeIdx) { + if (!appState.stepEditMode) return; + span.classList.add('step-field-editable'); + span.title = 'Double-click to edit'; + + span.addEventListener('dblclick', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (span.querySelector('input')) return; // already editing + + const original = span.textContent; + // For duration fields strip the leading label text so user edits just the value + let editValue = original; + if (field === 'duration') { + // "hold 300ms" -> "300ms", "Wait 500ms" -> "500ms", "230ms" -> "230ms" + editValue = original.replace(/^(hold\s+|Wait\s+)/i, '').trim(); + editValue = editValue.replace(/\s*✓\s*$/u, '').trim(); + } + + const input = document.createElement('input'); + input.type = 'text'; + input.value = editValue; + input.className = 'step-field-input'; + input.size = Math.max(4, editValue.length + 1); + span.textContent = ''; + span.appendChild(input); + input.focus(); + input.select(); + + const commit = () => { + const newVal = input.value.trim(); + span.textContent = original; + span.classList.remove('step-field-editing'); + if (newVal && newVal !== editValue) { + const ok = commitStepFieldEdit(runtimeIdx, s, field, newVal); + if (!ok) { + span.title = 'Invalid value — double-click to try again'; + } + } + }; + + const cancel = () => { + span.textContent = original; + span.classList.remove('step-field-editing'); + }; + + span.classList.add('step-field-editing'); + input.addEventListener('blur', commit); + input.addEventListener('keydown', (ke) => { + if (ke.key === 'Enter') { ke.preventDefault(); input.blur(); } + if (ke.key === 'Escape') { ke.preventDefault(); input.removeEventListener('blur', commit); cancel(); } + }); + }); +} + +// Timeline rendering +function refreshTimelineIfLoaded() { + if (appState.lastTimelineSteps) updateTimeline(appState.lastTimelineSteps); +} + +function updateTimeline(steps, opts) { + opts = opts || {}; + const scrollOpts = { focusLatest: !!opts.focusLatest }; + appState.lastTimelineSteps = steps; + const container = getEl('comboTimeline'); + if (!container) return; + container.innerHTML = ''; + + const ctx = { + failByStep: appState.lastFailByStep, + stepDisplayMode: appState.stepDisplayMode, + keyImages: appState.keyImages, + targetGame: appState.targetGame, + wwSwapImages: appState.wwSwapImages, + wwDashImage: appState.wwDashImage, + wwLmbImages: appState.wwLmbImages, + wwAbilityImages: appState.wwAbilityImages, + showFailCount: appState.showFailCount, + }; + + // Names each step's move ("Zani Basic 2"); steps must be labeled in timeline order. + const labelMove = appState.showMoveNames ? createWwMoveLabeler(wwSlotNames()) : null; + function appendMoveLabel(tile, step, slot) { + if (!labelMove) return; + const text = labelMove(step, slot); + if (!text) return; + const el = document.createElement('span'); + el.className = 'step-move'; + el.textContent = text; + el.title = text; + tile.appendChild(el); + } + + const viewport = getEl('comboTimelineViewport'); + const isAutoScroll = viewport?.classList.contains('auto-scroll-on'); + let baseStepWidthPx = 90; + if (isAutoScroll) { + const probe = document.createElement('div'); + probe.style.cssText = 'position:absolute;visibility:hidden;min-width:var(--auto-scroll-step-min-width)'; + document.body.appendChild(probe); + const computedPx = getComputedStyle(probe).minWidth; + document.body.removeChild(probe); + const parsed = parseFloat(computedPx, 10); + if (Number.isFinite(parsed) && parsed > 0) baseStepWidthPx = parsed; + } + const DURATION_WIDTH_DIVISOR = 350; + const applyHoldWidth = (el, durationMs) => { + const ms = Number(durationMs); + const mult = (Number.isFinite(ms) && ms > 0) ? (ms / DURATION_WIDTH_DIVISOR) : 1; + const w = Math.max(baseStepWidthPx, baseStepWidthPx * mult); + el.style.minWidth = `${baseStepWidthPx}px`; + el.style.width = `${w}px`; + }; + const applyWaitWidth = (el, durationMs) => { + const ms = Number(durationMs); + const mult = (Number.isFinite(ms) && ms > 0) ? (ms / DURATION_WIDTH_DIVISOR) : 1; + const w = Math.max(baseStepWidthPx, baseStepWidthPx * mult); + el.style.minWidth = `${baseStepWidthPx}px`; + el.style.width = `${w}px`; + }; + const applyBaseWidth = (el) => { + el.style.minWidth = `${baseStepWidthPx}px`; + el.style.width = `${baseStepWidthPx}px`; + }; + const addCornerKey = (el, key, s, runtimeIdx) => { + if (ctx.stepDisplayMode !== 'images') return; + const k = (key || '').toString().trim(); + if (!k) return; + const span = document.createElement('span'); + span.className = 'corner-key'; + span.textContent = k.toUpperCase(); + el.appendChild(span); + if (s && runtimeIdx != null) attachInlineEdit(span, s, 'key', runtimeIdx); + }; + const parseStepIndices = (stepIndices) => { + if (!Array.isArray(stepIndices)) return []; + return stepIndices + .map(v => Number.parseInt(v, 10)) + .filter(v => Number.isFinite(v) && v >= 0); + }; + const attachStepDeleteControl = (el, stepIndices) => { + const indices = parseStepIndices(stepIndices); + if (!appState.stepEditMode || indices.length === 0) return; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'step-delete-btn'; + btn.title = 'Delete this step'; + btn.setAttribute('aria-label', 'Delete this step'); + btn.textContent = '🗑'; + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + pushEditStepsUndoSnapshot(); + sendMessage('delete_timeline_step', { step_indices: indices }); + }); + el.appendChild(btn); + }; + + // Drag-to-reorder: the first runtime index stored in step_indices is used as the drag handle identifier. + const attachStepDragControl = (el, stepIndices) => { + const indices = parseStepIndices(stepIndices); + if (!appState.stepEditMode || indices.length === 0) return; + const fromRuntimeIdx = indices[0]; + el.setAttribute('draggable', 'true'); + el.dataset.runtimeIdx = String(fromRuntimeIdx); + + el.addEventListener('dragstart', (ev) => { + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData('text/plain', String(fromRuntimeIdx)); + el.classList.add('step-dragging'); + }); + + el.addEventListener('dragend', () => { + el.classList.remove('step-dragging'); + container.querySelectorAll('.step-drag-over-before, .step-drag-over-after').forEach(t => { + t.classList.remove('step-drag-over-before', 'step-drag-over-after'); + }); + }); + + el.addEventListener('dragover', (ev) => { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + const draggingIdx = ev.dataTransfer.getData('text/plain'); + if (draggingIdx === String(fromRuntimeIdx)) return; + const rect = el.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + container.querySelectorAll('.step-drag-over-before, .step-drag-over-after').forEach(t => { + t.classList.remove('step-drag-over-before', 'step-drag-over-after'); + }); + if (ev.clientX < midX) { + el.classList.add('step-drag-over-before'); + } else { + el.classList.add('step-drag-over-after'); + } + }); + + el.addEventListener('dragleave', (ev) => { + if (!el.contains(ev.relatedTarget)) { + el.classList.remove('step-drag-over-before', 'step-drag-over-after'); + } + }); + + el.addEventListener('drop', (ev) => { + ev.preventDefault(); + const draggedRuntimeIdx = Number.parseInt(ev.dataTransfer.getData('text/plain'), 10); + el.classList.remove('step-drag-over-before', 'step-drag-over-after'); + if (!Number.isFinite(draggedRuntimeIdx) || draggedRuntimeIdx === fromRuntimeIdx) return; + pushEditStepsUndoSnapshot(); + const rect = el.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + if (ev.clientX < midX) { + // Drop before this tile + sendMessage('reorder_timeline_step', { + from_step_index: draggedRuntimeIdx, + before_step_index: fromRuntimeIdx, + }); + } else { + // Drop after this tile: find the next sibling's runtime index, or null to append. + const allTiles = Array.from(container.querySelectorAll('[data-runtime-idx]')); + const selfIdx = allTiles.indexOf(el); + const nextTile = allTiles[selfIdx + 1] || null; + const beforeIdx = nextTile ? Number.parseInt(nextTile.dataset.runtimeIdx, 10) : null; + sendMessage('reorder_timeline_step', { + from_step_index: draggedRuntimeIdx, + before_step_index: Number.isFinite(beforeIdx) ? beforeIdx : null, + }); + } + }); + }; + + function createGroupItemTile(it, characterId) { + const el = document.createElement('div'); + el.className = 'step group-item'; + + if (it.type === 'wait') { + el.classList.add('wait'); + if (it.duration <= SHORT_WAIT_MS) el.classList.add('short-wait'); + const pct = (it.progress !== undefined && it.progress !== null) ? it.progress : (it.completed ? 100 : 0); + el.style.setProperty('--wait-pct', `${pct}%`); + applyWaitWidth(el, it.duration); + } else if (it.type === 'press_wait' || it.type === 'spam') { + el.classList.add('press-wait'); + if (it.type === 'spam') el.classList.add('spam'); + if (it.duration <= SHORT_WAIT_MS) el.classList.add('short-wait'); + const pct = (it.progress !== undefined && it.progress !== null) ? it.progress : (it.completed ? 100 : 0); + el.style.setProperty('--wait-pct', `${pct}%`); + applyWaitWidth(el, it.duration); + } else if (it.type === 'hold') { + el.classList.add('hold'); + applyHoldWidth(el, it.duration); + el.style.setProperty('--hold-pct', it.completed ? '100%' : '0%'); + } else if (isAutoScroll) { + applyBaseWidth(el); + } + if (it.optional) el.classList.add('optional'); + if (it.optional && it.completed && !it.was_skipped) el.classList.add('was-pressed'); + + if (it.active) el.classList.add('active'); + if (it.completed) el.classList.add('completed'); + + appendStepContent(el, it, characterId, ctx); + appendMoveLabel(el, it, characterId); + + let keyForCorner = ''; + if (it.type === 'wait' && it.wait_for) keyForCorner = it.wait_for; + else if (it.input) keyForCorner = it.input; + if (keyForCorner) addCornerKey(el, keyForCorner); + + return el; + } + + function renderGroupStep(s, idx, activeChar) { + const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; + const failCount = indices.reduce((n, i) => n + (ctx.failByStep[String(i)] || ctx.failByStep[i] || 0), 0); + const showFailForStep = ctx.showFailCount && failCount > 0; + + const tile = document.createElement('div'); + tile.className = 'step-group'; + if (s.active) tile.classList.add('active'); + if (s.completed) tile.classList.add('completed'); + if (s.mark) { + const m = String(s.mark).toLowerCase(); + if (m === 'ok') tile.classList.add('mark-ok'); + else if (m === 'early') tile.classList.add('mark-early'); + else if (m === 'missed') tile.classList.add('mark-missed'); + else if (m === 'wrong') tile.classList.add('mark-wrong'); + } + if (showFailForStep) { + tile.classList.add('mark-missed'); + const badge = document.createElement('span'); + badge.className = 'step-fail-count'; + badge.textContent = String(failCount); + tile.appendChild(badge); + } + + const items = document.createElement('div'); + items.className = 'step-group-items'; + let nextChar = activeChar; + + (s.items || []).forEach(it => { + const itInp = (it.input || '').toString().toLowerCase(); + const itWait = (it.wait_for || '').toString().toLowerCase(); + + if (it.type === 'sequence') { + const seqEl = document.createElement('div'); + seqEl.className = 'step group-item group-item-sequence'; + if (it.active) seqEl.classList.add('active'); + if (it.completed) seqEl.classList.add('completed'); + + const seqItems = document.createElement('div'); + seqItems.className = 'mini-sequence-items'; + (it.items || []).forEach(seqIt => { + const seqItInp = (seqIt.input || '').toString().toLowerCase(); + const seqItWait = (seqIt.wait_for || '').toString().toLowerCase(); + if (['1', '2', '3'].includes(seqItInp)) nextChar = seqItInp; + else if (['1', '2', '3'].includes(seqItWait)) nextChar = seqItWait; + seqItems.appendChild(createGroupItemTile(seqIt, nextChar)); + }); + seqEl.appendChild(seqItems); + items.appendChild(seqEl); + } else { + if (['1', '2', '3'].includes(itInp)) nextChar = itInp; + else if (['1', '2', '3'].includes(itWait)) nextChar = itWait; + items.appendChild(createGroupItemTile(it, nextChar)); + } + }); + tile.appendChild(items); + attachStepDeleteControl(tile, s.step_indices); + attachStepDragControl(tile, s.step_indices); + return { tile, nextActiveChar: nextChar }; + } + + function renderSequenceStep(s, idx, activeChar) { + const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; + const failCount = indices.reduce((n, i) => n + (ctx.failByStep[String(i)] || ctx.failByStep[i] || 0), 0); + const showFailForStep = ctx.showFailCount && failCount > 0; + + const tile = document.createElement('div'); + tile.className = 'step-sequence'; + if (s.active) tile.classList.add('active'); + if (s.completed) tile.classList.add('completed'); + if (showFailForStep) { + tile.classList.add('mark-missed'); + const badge = document.createElement('span'); + badge.className = 'step-fail-count'; + badge.textContent = String(failCount); + tile.appendChild(badge); + } + + const items = document.createElement('div'); + items.className = 'sequence-items'; + let nextChar = activeChar; + + (s.items || []).forEach(it => { + const itInp = (it.input || '').toString().toLowerCase(); + const itWait = (it.wait_for || '').toString().toLowerCase(); + if (['1', '2', '3'].includes(itInp)) nextChar = itInp; + else if (['1', '2', '3'].includes(itWait)) nextChar = itWait; + + const itEl = document.createElement('div'); + itEl.className = 'step sequence-item'; + if (it.optional) itEl.classList.add('optional'); + if (it.optional && it.completed && !it.was_skipped) itEl.classList.add('was-pressed'); + if (it.active) itEl.classList.add('active'); + if (it.completed) itEl.classList.add('completed'); + appendStepContent(itEl, it, nextChar, ctx); + appendMoveLabel(itEl, it, nextChar); + items.appendChild(itEl); + }); + tile.appendChild(items); + attachStepDeleteControl(tile, s.step_indices); + attachStepDragControl(tile, s.step_indices); + return { tile, nextActiveChar: nextChar }; + } + + function renderNormalStep(s, idx, activeChar) { + const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; + const failCount = indices.reduce((n, i) => n + (ctx.failByStep[String(i)] || ctx.failByStep[i] || 0), 0); + const showFailForStep = ctx.showFailCount && failCount > 0; + + const sInp = (s.input || '').toString().toLowerCase(); + const sWait = (s.wait_for || '').toString().toLowerCase(); + let nextChar = activeChar; + if (['1', '2', '3'].includes(sInp)) nextChar = sInp; + else if (['1', '2', '3'].includes(sWait)) nextChar = sWait; + + const tile = document.createElement('div'); + tile.className = 'step'; + if (s.active) tile.classList.add('active'); + if (s.completed) tile.classList.add('completed'); + if (s.mark === 'success') tile.classList.add('mark-ok'); + if (s.mark === 'fail' || s.mark === 'wrong') tile.classList.add('mark-wrong'); + if (s.mark === 'missed' || showFailForStep) tile.classList.add('mark-missed'); + if (s.mark === 'early') tile.classList.add('mark-early'); + if (showFailForStep) { + const badge = document.createElement('span'); + badge.className = 'step-fail-count'; + badge.textContent = String(failCount); + tile.appendChild(badge); + } + + if (s.type) tile.classList.add(s.type.replace(/_/g, '-')); + if (s.optional) tile.classList.add('optional'); + if (s.optional && s.completed && !s.was_skipped) tile.classList.add('was-pressed'); + let pct = (s.progress !== undefined) ? s.progress : (s.completed ? 100 : 0); + if (s.type === 'wait' || s.type === 'press_wait' || s.type === 'spam') { + tile.style.setProperty('--wait-pct', `${pct}%`); + if (s.duration <= SHORT_WAIT_MS) tile.classList.add('short-wait'); + if (s.duration) applyWaitWidth(tile, s.duration); + } else if (s.type === 'hold' || s.type === 'hold_with_body') { + tile.style.setProperty('--hold-pct', `${pct}%`); + if (s.duration) applyHoldWidth(tile, s.duration); + } else if (isAutoScroll) { + applyBaseWidth(tile); + } + + const runtimeIdxNormal = Array.isArray(s.step_indices) ? s.step_indices[0] : idx; + let keyForCorner = ''; + if (s.type === 'wait' && s.wait_for) keyForCorner = s.wait_for; + else if (s.input) keyForCorner = s.input; + if (keyForCorner && s.type !== 'hold_with_body') addCornerKey(tile, keyForCorner, s, runtimeIdxNormal); + + appendStepContent(tile, s, nextChar, ctx, runtimeIdxNormal); + appendMoveLabel(tile, s, nextChar); + attachStepDeleteControl(tile, s.step_indices); + attachStepDragControl(tile, s.step_indices); + return { tile, nextActiveChar: nextChar }; + } + + function renderStep(s, idx, activeChar) { + const out = (s.type === 'group') + ? renderGroupStep(s, idx, activeChar) + : (s.type === 'sequence') + ? renderSequenceStep(s, idx, activeChar) + : renderNormalStep(s, idx, activeChar); + const indices = Array.isArray(s.step_indices) ? s.step_indices : [idx]; + out.tile.dataset.stepIndices = indices.join(','); + return out; + } + + const buildCollapsedLmbPressWaitStep = (chainSteps) => { + const flattenedIndices = chainSteps.flatMap((it) => + Array.isArray(it.step_indices) ? it.step_indices : [] + ); + const totalDuration = chainSteps.reduce((sum, it) => sum + (Number(it.duration) || 0), 0); + const progressTotal = chainSteps.reduce((sum, it) => { + const pct = (it.progress !== undefined && it.progress !== null) ? Number(it.progress) : (it.completed ? 100 : 0); + return sum + (Number.isFinite(pct) ? pct : 0); + }, 0); + const avgProgress = chainSteps.length > 0 ? (progressTotal / chainSteps.length) : 0; + const anyMark = (name) => chainSteps.some((it) => (it.mark || '') === name); + let collapsedMark = ''; + if (anyMark('wrong') || anyMark('fail')) collapsedMark = 'wrong'; + else if (anyMark('missed')) collapsedMark = 'missed'; + else if (anyMark('early')) collapsedMark = 'early'; + else if (anyMark('success')) collapsedMark = 'success'; + + return { + ...chainSteps[0], + type: 'press_wait', + input: 'lmb', + duration: totalDuration, + progress: avgProgress, + chain_count: chainSteps.length, + chain_collapsed: true, + active: chainSteps.some((it) => !!it.active), + completed: chainSteps.every((it) => !!it.completed), + step_indices: flattenedIndices, + mark: collapsedMark, + }; + }; + + if (!steps || steps.length === 0) { + container.innerHTML = '
No combo selected
'; + return; + } + + const isLmbPressWait = (step) => + !!step + && step.type === 'press_wait' + && ((step.input || '').toString().toLowerCase() === 'lmb'); + + const collapseChains = !!appState.collapseChainedPresses; + let activeChar = '1'; + for (let idx = 0; idx < steps.length; idx += 1) { + const s = steps[idx]; + + if (collapseChains && isLmbPressWait(s)) { + let chainEnd = idx + 1; + while (chainEnd < steps.length && isLmbPressWait(steps[chainEnd])) chainEnd += 1; + const chainLen = chainEnd - idx; + if (chainLen > 1) { + const collapsed = buildCollapsedLmbPressWaitStep(steps.slice(idx, chainEnd)); + const { tile, nextActiveChar } = renderStep(collapsed, idx, activeChar); + tile.classList.add('chain-collapsed'); + activeChar = nextActiveChar; + container.appendChild(tile); + idx = chainEnd - 1; + continue; + } + } + + const { tile, nextActiveChar } = renderStep(s, idx, activeChar); + activeChar = nextActiveChar; + container.appendChild(tile); + if (!collapseChains && isLmbPressWait(s) && isLmbPressWait(steps[idx + 1])) { + const dash = document.createElement('div'); + dash.className = 'timeline-chain-dash'; + dash.setAttribute('aria-hidden', 'true'); + container.appendChild(dash); + } + } + + renderAvgSplitsOnTimeline(); + + if (viewport?.classList.contains('auto-scroll-on')) { + requestAnimationFrame(() => { + normalizeStepHeightsInAutoScroll(container); + applyAutoScroll(scrollOpts); + }); + } else if (appState.autoScrollEnabled) { + requestAnimationFrame(() => applyAutoScroll(scrollOpts)); + } +} + +function normalizeStepHeightsInAutoScroll(timelineEl) { + if (!timelineEl) return; + const stepTiles = timelineEl.querySelectorAll(':scope > .step, :scope .step.group-item'); + if (stepTiles.length === 0) return; + stepTiles.forEach(el => { el.style.minHeight = ''; }); + const heights = Array.from(stepTiles).map(el => el.getBoundingClientRect().height); + const maxH = Math.max(...heights, 0); + if (maxH > 0) stepTiles.forEach(el => { el.style.minHeight = `${maxH}px`; }); +} + +function renderStepLabel(s) { + if (s.type === 'sequence') { + const parts = (s.items || []).map(it => renderStepLabel(it)); + return parts.length > 0 ? `Seq: ${parts.join('→')}` : 'Seq'; + } + const inp = (s.input || '').toString().toUpperCase(); + const dur = s.duration || 0; + if (s.type === 'wait') { + if (s.mode === 'mandatory' && s.wait_for) { + return `${s.wait_for.toUpperCase()} (${dur}ms)`; + } + return `Wait ${dur}ms`; + } + if (s.type === 'hold') return `Hold ${inp} ${dur}ms`; + if (s.type === 'hold_with_body') return `Hold ${inp} ${dur}ms (+body)`; + if (s.type === 'spam') return `Spam ${inp} ${dur}ms`; + if (s.type === 'press_wait') return `${inp} + ${dur}ms`; + return inp; +} + +function getMouseIconSvg(type) { + const t = type.toLowerCase(); + if (t === 'lmb') { + return ``; + } + if (t === 'rmb') { + return ``; + } + if (t === 'mmb') { + return ``; + } + return ''; +} + +function appendStepContent(parent, s, characterId, ctx, runtimeIdx) { + const useImages = (ctx && ctx.stepDisplayMode === 'images') || !!getEl('stepDisplayToggle')?.checked; + const inp = (s.input || '').toString().toLowerCase(); + const label = (s.input || '').toString().toUpperCase(); + const charId = characterId || '1'; + + // Helper to decide content (icon or text when no image) + const appendIconOrText = (key, fallbackText, target = parent) => { + const svg = getMouseIconSvg(key); + if (svg) { + const icon = document.createElement('span'); + icon.className = 'mouse-icon'; + icon.innerHTML = svg; + target.appendChild(icon); + return; + } + const span = document.createElement('span'); + span.className = 'step-primary'; + if (key === 'space') { + span.textContent = '⎵'; + span.classList.add('space-icon'); + } else { + span.textContent = fallbackText; + } + target.appendChild(span); + }; + + // Resolve image URL based on current game/mode (WW vs generic); returns null when no image. + const resolveImage = (key) => { + if (!useImages) return null; + if (ctx && ctx.targetGame === 'wuthering_waves') { + return getWwImage(key, charId, ctx); + } + return (ctx && ctx.keyImages[key]) || null; + }; + + // Append primary content: image if available, else icon/text. + const appendPrimary = (key, fallbackText, target = parent) => { + const imgUrl = resolveImage(key); + if (imgUrl) { + target.appendChild(createImageElement(imgUrl)); + } else { + appendIconOrText(key, fallbackText, target); + } + }; + + // Append duration/secondary text; attach inline edit when runtimeIdx is provided. + const appendDuration = (text, target = parent) => { + const dur = document.createElement('span'); + dur.className = 'step-secondary'; + dur.textContent = text; + target.appendChild(dur); + if (runtimeIdx != null) attachInlineEdit(dur, s, 'duration', runtimeIdx); + }; + + // Single unified logic — no duplication across WW / generic / icons. + if (s.type === 'wait' && s.mode === 'mandatory' && s.wait_for) { + const key = s.wait_for.toLowerCase(); + appendPrimary(key, key.toUpperCase()); + appendDuration(`${s.duration}ms`); + } else if (s.type === 'hold_with_body') { + const shell = document.createElement('div'); + shell.className = 'hold-body-layout'; + + const anchor = document.createElement('div'); + anchor.className = 'hold-anchor'; + appendPrimary(inp, label, anchor); + + const keyTag = document.createElement('span'); + keyTag.className = 'corner-key hold-anchor-key'; + keyTag.textContent = label; + anchor.appendChild(keyTag); + if (runtimeIdx != null) attachInlineEdit(keyTag, s, 'key', runtimeIdx); + + const right = document.createElement('div'); + right.className = 'hold-content'; + const bodyDoneLabel = s.body_done ? ' ✓' : ''; + appendDuration(`hold ${s.duration}ms${bodyDoneLabel}`, right); + + const bodyRow = document.createElement('div'); + bodyRow.className = 'hold-body-row'; + const bodyItems = (s.body && Array.isArray(s.body.items)) ? s.body.items : []; + bodyItems.forEach((it) => { + const chip = document.createElement('span'); + const isActive = !!(it && it.active); + const isCompleted = !!(it && it.completed); + const itemType = (it && it.type ? String(it.type) : '').toLowerCase(); + if (itemType === 'wait') { + chip.className = 'hold-body-chip wait'; + chip.textContent = `${Number(it.duration || 0)}ms`; + const waitPct = Number(it.progress); + if (Number.isFinite(waitPct)) { + chip.style.setProperty('--chip-wait-pct', `${Math.max(0, Math.min(100, waitPct))}%`); + } + } else { + chip.className = 'hold-body-chip press'; + const key = (it && it.input ? it.input : '').toString().toLowerCase(); + if (key) { + const imgUrl = resolveImage(key); + if (imgUrl) chip.appendChild(createImageElement(imgUrl)); + else appendIconOrText(key, key.toUpperCase(), chip); + } else { + chip.textContent = '?'; + } + // Body sequences often arrive as press_wait (collapsed press + following wait). + // Render the wait timing inline so all hold-body waits stay visible. + if (itemType === 'press_wait' || itemType === 'spam') { + chip.classList.add('press-wait'); + if (itemType === 'spam') chip.classList.add('spam'); + const inlineWait = document.createElement('span'); + inlineWait.className = 'hold-body-inline-wait'; + inlineWait.textContent = itemType === 'spam' + ? `spam ${Number(it.duration || 0)}ms` + : `${Number(it.duration || 0)}ms`; + const waitPct = Number(it.progress); + if (Number.isFinite(waitPct)) { + inlineWait.style.setProperty('--chip-wait-pct', `${Math.max(0, Math.min(100, waitPct))}%`); + } + chip.appendChild(inlineWait); + } + } + if (isActive) chip.classList.add('active'); + if (isCompleted) chip.classList.add('completed'); + bodyRow.appendChild(chip); + }); + right.appendChild(bodyRow); + + shell.appendChild(anchor); + shell.appendChild(right); + parent.appendChild(shell); + } else if (s.type === 'hold') { + appendPrimary(inp, label); + appendDuration(`hold ${s.duration}ms`); + } else if (s.type === 'spam') { + appendPrimary(inp, label); + appendDuration(`spam ${s.duration}ms`); + } else if (s.type === 'press_wait') { + const chainCount = Number(s.chain_count || 0); + if (chainCount > 1) { + const primaryRow = document.createElement('div'); + primaryRow.className = 'step-chain-primary'; + appendPrimary(inp, label, primaryRow); + const count = document.createElement('span'); + count.className = 'step-chain-count'; + count.textContent = `x${chainCount}`; + primaryRow.appendChild(count); + parent.appendChild(primaryRow); + } else { + appendPrimary(inp, label); + } + appendDuration(`${s.duration}ms`); + } else if (s.type === 'wait') { + appendDuration(`Wait ${s.duration}ms`); + } else { + appendPrimary(inp, label); + } +} + +function getWwImage(key, characterId, ctx) { + if (!ctx) return null; + const k = key.toLowerCase(); + const cid = characterId || '1'; + + // Check if it's a swap key (1/2/3) - Return the swap icon for that character regardless of who is active + if (['1', '2', '3'].includes(k)) { + return ctx.wwSwapImages[k] || null; + } + // Check if it's RMB (dash) - shared dash image + if (k === 'rmb') { + return ctx.wwDashImage || null; + } + + // Check if it's LMB - use active character + if (k === 'lmb') { + return ctx.wwLmbImages[cid] || null; + } + + // Check if it's an ability (e/q/r) - use active character + if (['e', 'q', 'r'].includes(k)) { + if (ctx.wwAbilityImages[cid] && ctx.wwAbilityImages[cid][k]) { + return ctx.wwAbilityImages[cid][k]; + } + // Fallback: search all characters if not found for specific one (legacy behavior, optional) + for (const c of ['1', '2', '3']) { + if (ctx.wwAbilityImages[c] && ctx.wwAbilityImages[c][k]) { + return ctx.wwAbilityImages[c][k]; + } + } + } + return null; +} + +function createImageElement(url) { + const img = document.createElement('span'); + img.className = 'key-img-wrap'; // Matches CSS .key-img-wrap + if (/^https?:\/\//i.test(url)) { + img.innerHTML = ``; + } else { + img.innerHTML = `${escapeHtml(url)}`; + } + return img; +} + +function setAutoScrollEnabled(enabled) { + appState.autoScrollEnabled = !!enabled; + const vp = getEl('comboTimelineViewport'); + const timeline = getEl('comboTimeline'); + if (vp) { + if (appState.autoScrollEnabled) { + vp.classList.add('auto-scroll-on'); + refreshTimelineIfLoaded(); + } else { + vp.classList.remove('auto-scroll-on'); + if (timeline) timeline.style.transform = 'none'; + refreshTimelineIfLoaded(); + } + } +} + +/** + * Element to center when auto-scroll is on. + * Prefers the last top-level tile when `focusLatest` is set (server: transcription updates) or when + * transcribe checkbox is on (main UI). Otherwise uses `.step.active` (combo practice / playback). + */ +function autoScrollTimelineTargetEl(timeline, scrollOpts) { + scrollOpts = scrollOpts || {}; + if (!timeline) return null; + const preferLatest = + !!scrollOpts.focusLatest + || !!getEl('transcribeModeToggle')?.checked; + if (preferLatest) { + const tops = [...timeline.children].filter( + (c) => + c.classList.contains('step-group') + || c.classList.contains('step-sequence') + || (c.classList.contains('step') + && !c.classList.contains('group-item') + && !c.classList.contains('sequence-item')), + ); + if (tops.length) return tops[tops.length - 1]; + } + return timeline.querySelector('.step.active'); +} + +function applyAutoScroll(scrollOpts) { + if (!appState.autoScrollEnabled) return; + const viewport = getEl('comboTimelineViewport'); + const timeline = getEl('comboTimeline'); + if (!viewport || !timeline) return; + + const target = autoScrollTimelineTargetEl(timeline, scrollOpts); + if (!target) return; + + const vpRect = viewport.getBoundingClientRect(); + const activeRect = target.getBoundingClientRect(); + + const vpCenter = vpRect.left + (vpRect.width / 2); + const activeCenter = activeRect.left + (activeRect.width / 2); + const offset = activeCenter - vpCenter; + + // We must use transform for positioning as per layout + const style = window.getComputedStyle(timeline); + let currentX = 0; + if (style.transform && style.transform !== 'none') { + try { + // Logic to parse matrix(1, 0, 0, 1, x, y) + const matrix = new DOMMatrix(style.transform); + currentX = matrix.m41; + } catch (e) { + console.error('AutoScroll matrix parse error', e); + } + } + + // Shift to left to compensate positive offset (right-side target) + const newX = currentX - offset; + timeline.style.transform = `translateX(${newX}px)`; +} + diff --git a/static/characters.css b/static/characters.css index 6cec4bb..d279116 100644 --- a/static/characters.css +++ b/static/characters.css @@ -1,15 +1,18 @@ /* Characters page. Colors match style.css (the main tracker page). */ :root { - --bg-primary: #1a1a1a; - --bg-secondary: #2a2a2a; - --bg-hover: #3a3a3a; - --text-primary: #e0e0e0; - --text-secondary: #a0a0a0; - --accent-blue: #4a9eff; - --accent-green: #4aff88; - --accent-yellow: #ffdd4a; - --accent-red: #ff4a4a; - --radius: 8px; + /* BetterGhub theme, same tokens as shell.css on the main page */ + --bg-primary: #0B0D10; + --bg-secondary: #14181E; + --bg-hover: #222832; + --text-primary: #ECEFF3; + --text-secondary: #8D97A5; + --accent-blue: #2EC5EA; + --accent-green: #3DDC84; + --accent-yellow: #F4B740; + --accent-red: #F05A63; + --line: #232933; + --line-strong: #343C48; + --radius: 10px; --el-aero: #55d6a8; --el-electro: #b67cff; @@ -24,7 +27,7 @@ body { background: var(--bg-primary); color: var(--text-primary); - font-family: 'Segoe UI', 'Roboto', sans-serif; + font-family: 'IBM Plex Sans', 'Segoe UI', 'Roboto', sans-serif; font-size: 14px; line-height: 1.5; } @@ -37,7 +40,7 @@ a { color: var(--accent-blue); } button, .btn { background: var(--bg-hover); color: var(--text-primary); - border: 1px solid #4a4a4a; + border: 1px solid var(--line-strong); border-radius: 6px; padding: 6px 12px; font: inherit; @@ -54,7 +57,7 @@ button.subtle, .btn.subtle { background: transparent; } input[type="search"], select, textarea { background: var(--bg-primary); color: var(--text-primary); - border: 1px solid #4a4a4a; + border: 1px solid var(--line-strong); border-radius: 6px; padding: 6px 10px; font: inherit; @@ -71,7 +74,7 @@ input[type="search"], select, textarea { gap: 10px 14px; padding: 12px 20px; background: var(--bg-secondary); - border-bottom: 1px solid #333; + border-bottom: 1px solid var(--line); } .topbar h1 { font-size: 18px; } .topbar .back { text-decoration: none; color: var(--text-secondary); } @@ -81,7 +84,7 @@ input[type="search"], select, textarea { .chips { display: flex; flex-wrap: wrap; gap: 6px; } .chip { padding: 3px 10px; border-radius: 999px; font-size: 13px; } -.chip.on { background: var(--accent-blue); border-color: var(--accent-blue); color: #0b1a2c; } +.chip.on { background: #152E38; border-color: var(--accent-blue); color: var(--text-primary); } .dot { display: inline-block; @@ -153,7 +156,7 @@ input[type="search"], select, textarea { max-width: 100%; } .char-name .dot { width: 6px; height: 6px; margin-right: 4px; } -.char.selected { border-color: var(--accent-blue); background: #23303f; } +.char.selected { border-color: var(--accent-blue); background: #152E38; } .detail { background: var(--bg-secondary); @@ -168,7 +171,7 @@ input[type="search"], select, textarea { .detail-head img { width: 72px; height: 72px; border-radius: 50%; background: var(--bg-hover); } .detail-head h2 { font-size: 22px; } -.tabs { display: flex; gap: 4px; border-bottom: 1px solid #3a3a3a; margin-bottom: 16px; } +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 16px; } .tabs button { border: none; border-bottom: 2px solid transparent; @@ -217,7 +220,7 @@ input[type="search"], select, textarea { .desc h5:first-child { margin-top: 0; } .desc p { margin: 0 0 4px; color: #cfcfcf; } .mult { border-collapse: collapse; width: 100%; font-size: 13px; align-self: start; } -.mult th, .mult td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; } +.mult th, .mult td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); } .mult th { color: var(--text-secondary); font-weight: 500; } .mult td:last-child { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } @@ -245,15 +248,15 @@ input[type="search"], select, textarea { } .team-name { font-weight: 600; } .team-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 12px; margin: 2px 0 6px; } -.tag { background: #23303f; color: #a9cfff; border-radius: 4px; padding: 0 6px; } +.tag { background: #152E38; color: #9BE6F7; border-radius: 4px; padding: 0 6px; } .tag.subtle { background: var(--bg-hover); color: var(--text-primary); } -.dps-bar { height: 4px; background: #333; border-radius: 2px; overflow: hidden; } +.dps-bar { height: 4px; background: var(--line-strong); border-radius: 2px; overflow: hidden; } .dps-bar span { display: block; height: 100%; background: var(--accent-blue); } .team-num { text-align: right; display: flex; flex-direction: column; line-height: 1.2; font-variant-numeric: tabular-nums; } .team-num .muted { font-size: 11px; } .team-actions { display: flex; gap: 6px; } .team-actions button, .team-actions .btn { padding: 4px 10px; font-size: 13px; } -.transcript { grid-column: 1 / -1; border-top: 1px solid #333; padding-top: 10px; margin-top: 4px; } +.transcript { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 10px; margin-top: 4px; } .transcript h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); margin: 10px 0 6px; } .transcript h4:first-child { margin-top: 0; } .step { display: flex; gap: 10px; align-items: baseline; padding: 3px 0; } @@ -267,7 +270,7 @@ input[type="search"], select, textarea { padding: 1px 6px; } .move[title] { cursor: help; text-decoration: underline dotted #777; } -.move.swap { background: #23303f; color: #a9cfff; } +.move.swap { background: #152E38; color: #9BE6F7; } .arrow { color: #666; } .glossary { margin-top: 10px; font-size: 12px; } .glossary summary { cursor: pointer; color: var(--text-secondary); } @@ -289,3 +292,11 @@ input[type="search"], select, textarea { .topbar input[type="search"] { flex: 1; width: auto; } .who { width: 70px; } } + +/* Embedded as the main app's Characters page (characters.html?embed=1): the app shell has the title and nav. */ +.embed body { background: var(--bg-secondary); } +.embed .topbar { position: static; background: transparent; padding: 14px 16px 0; border-bottom: 0; } +.embed .topbar .back, +.embed .topbar h1 { display: none; } +.embed .roster { top: 12px; max-height: calc(100vh - 24px); } +.embed .layout { padding: 16px; } diff --git a/static/characters.html b/static/characters.html index 9929a11..ee1548e 100644 --- a/static/characters.html +++ b/static/characters.html @@ -28,6 +28,7 @@

Characters

+ diff --git a/static/characters.js b/static/characters.js index e36a7d6..caba7c0 100644 --- a/static/characters.js +++ b/static/characters.js @@ -1,7 +1,9 @@ // Characters page: every character's moves (encore.moe) and community team rotations // (AntoCrasher's calc compilation + rotation transcripts), served by ui_server.py under /api/ww/. -const WS_URL = 'ws://localhost:8765'; +// Shown inside the main app's Characters page: hide this page's own back link and title. +if (new URLSearchParams(location.search).has('embed')) document.documentElement.classList.add('embed'); + const ELEMENTS = ['Aero', 'Electro', 'Fusion', 'Glacio', 'Havoc', 'Spectro']; const MAX_SKILL_LEVEL = 10; @@ -25,7 +27,7 @@ const state = { level: MAX_SKILL_LEVEL, kits: new Map(), transcripts: new Map(), - socket: null, + tracker: null, }; const $ = (id) => document.getElementById(id); @@ -96,35 +98,20 @@ async function api(path, refresh = false) { // Tracker connection (optional): marks your characters and saves rotations as combos // --------------------------------------------------------------------------- -function connectTracker() { - let socket; - try { - socket = new WebSocket(WS_URL); - } catch { - return; - } - socket.onmessage = (ev) => { - let msg; - try { - msg = JSON.parse(ev.data); - } catch { - return; - } - // The character list rides along in the editor payload (init and later editor updates). - const chars = (msg.editor && msg.editor.ww_characters) || msg.ww_characters; - if (Array.isArray(chars)) { - state.mine = chars.map((c) => nameTokens(c.name || c.name_key)); - renderRoster(); - } - if (msg.type === 'status' && msg.text && msg.color === 'fail') { - showNotice(msg.text, 'warn'); - } - }; - socket.onopen = () => (state.socket = socket); - socket.onclose = () => { - state.socket = null; - setTimeout(connectTracker, 3000); - }; +function watchTracker() { + state.tracker = connectTracker({ + onMessage: (msg) => { + // The character list rides along in the editor payload (init and later editor updates). + const chars = (msg.editor && msg.editor.ww_characters) || msg.ww_characters; + if (Array.isArray(chars)) { + state.mine = chars.map((c) => nameTokens(c.name || c.name_key)); + renderRoster(); + } + if (msg.type === 'status' && msg.text && msg.color === 'fail') { + showNotice(msg.text, 'warn'); + } + }, + }); } // --------------------------------------------------------------------------- @@ -353,7 +340,7 @@ function renderTranscript(t, tr) { ${unmapped}
- + Open transcript
`; @@ -384,16 +371,19 @@ function saveAsCombo(teamEl) { const inputs = teamEl.querySelector('.as-inputs textarea').value; const suggested = `${t.team}${t.author ? ` (${t.author})` : ''}`; const name = prompt('Save this rotation as a combo named:', suggested); - if (!name || !state.socket) return; - state.socket.send(JSON.stringify({ - type: 'save_combo', + if (!name || !state.tracker) return; + const sent = state.tracker.send('save_combo', { name, inputs, demo_video: safeUrl(t.video), target_game: 'wuthering_waves', as_new: true, - })); - showNotice(`Saved "${name}". Open the Combo Tracker to practice it and add timings.`, 'ok'); + }); + if (!sent) { + showNotice("Couldn't reach the tracker, so nothing was saved. Is ComboTracker running?", 'warn'); + return; + } + showNotice(`Saved "${name}". Open the Combos page to practice it and add timings.`, 'ok'); } // --------------------------------------------------------------------------- @@ -476,5 +466,5 @@ async function load(refresh = false) { renderFilters(); bindEvents(); -connectTracker(); +watchTracker(); load(); diff --git a/static/index.html b/static/index.html index 9d2f2cb..0353baf 100644 --- a/static/index.html +++ b/static/index.html @@ -5,328 +5,463 @@ WuWa Combo Tracker + + - + -
- - -
-
- -

Combo Input

-
-
- -
- - -
+
- -
- - - - -
+ + -
-
- - -
-
- -

Team & Characters

-
-
-
- - -
-
-

Teams

- - - - - +
+
+

Turn on Log attempts to list each step's time here.

+
+ + + + + + +
- - - - -
- - -
-
Success / fail—
-
Best—
-
Average—
-
- Fastest— - -
-
Difficulty—
-
Your difficulty—
-
Practical APM—
-
Max APM—
-
-
+ - -
-
-

Combo Steps

-
- Images - - - Auto scroll - + + - Move names - + + - Show attempt history - + +
- -
- Key images -
-
- Paste an image URL per key (leave blank to use the default icon/text). Keys are parsed from - Inputs. -
-
-
-
-
Status: Select a combo to start -
-
-
- -
-
-
- - -
-
-

Attempt Log

-
- - - -
-
-
-
- Input - Step Time (ms) - Total Time(ms) - Avg Step Time (ms) +
+

Data

+

Combos, teams and stats live in combos.json next to the app.

+
+ + + +
+
-
-
-
+ + + + + + + - + + + + + + diff --git a/static/keys.html b/static/keys.html index 6babb3e..0e26a20 100644 --- a/static/keys.html +++ b/static/keys.html @@ -37,6 +37,7 @@
SpaceJump
+ diff --git a/static/keys.js b/static/keys.js index 8f55649..75d9d42 100644 --- a/static/keys.js +++ b/static/keys.js @@ -1,8 +1,6 @@ // Live key overlay: lights up keys as the backend reports them (type "key_input"), // and labels 1/2/3 with the selected team's characters. -const WS_URL = 'ws://localhost:8765'; const keyEls = new Map(Array.from(document.querySelectorAll('.key')).map((el) => [el.dataset.key, el])); -let reconnectDelayMs = 500; function setDown(key, down) { const el = keyEls.get(key); @@ -36,25 +34,15 @@ function applyTeam(editor) { }); } -function connect() { - const socket = new WebSocket(WS_URL); - socket.onopen = () => { - reconnectDelayMs = 500; - keyEls.forEach((el) => el.classList.remove('offline')); - }; - socket.onclose = () => { +connectTracker({ + onOpen: () => keyEls.forEach((el) => el.classList.remove('offline')), + onClose: () => { releaseAll(); keyEls.forEach((el) => el.classList.add('offline')); - setTimeout(connect, reconnectDelayMs); - reconnectDelayMs = Math.min(reconnectDelayMs * 2, 5000); - }; - socket.onmessage = (event) => { - let msg; - try { msg = JSON.parse(event.data); } catch (_) { return; } + }, + onMessage: (msg) => { if (msg.type === 'key_input') setDown(msg.key, msg.down); else if (msg.type === 'init') applyTeam(msg.editor); else if (msg.type === 'combo_data') applyTeam(msg); - }; -} - -connect(); + }, +}); diff --git a/static/shell.css b/static/shell.css new file mode 100644 index 0000000..e892d45 --- /dev/null +++ b/static/shell.css @@ -0,0 +1,886 @@ +/* App shell in the style of BetterGhub (left rail, cards, cyan accent). + Loaded after style.css. The Combo Steps timeline keeps style.css's look on purpose: + tile colors (#444 tap, #ffdd4a wait, #4a9eff hold, #4aff88 done) carry established meanings. + The OBS views (?view=timeline and keys.html) are untouched. */ + +:root { + --ct-bg: #0B0D10; + --ct-rail: #101318; + --ct-panel: #14181E; + --ct-surface: #1A1F26; + --ct-surface2: #222832; + --ct-hover: #2A313C; + --ct-line: #232933; + --ct-line-strong: #343C48; + --ct-text: #ECEFF3; + --ct-muted: #8D97A5; + --ct-faint: #5D6673; + --ct-accent: #2EC5EA; + --ct-accent-hover: #62D6F3; + --ct-accent-dim: #152E38; + --ct-on-accent: #04141A; + --ct-success: #3DDC84; + --ct-warning: #F4B740; + --ct-danger: #F05A63; + + --ct-font-display: 'Barlow', 'Bahnschrift', 'Segoe UI', sans-serif; + --ct-font-body: 'IBM Plex Sans', 'Segoe UI', sans-serif; + --ct-font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +} + +body { + background: var(--ct-bg); + color: var(--ct-text); + font-family: var(--ct-font-body); +} + +a { color: var(--ct-accent); } +a:hover { color: var(--ct-accent-hover); } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.muted { color: var(--ct-muted); } +.small { font-size: 12.5px; } +[hidden] { display: none !important; } + +/* --------------------------------------------------------------------------- + Layout: rail + main + --------------------------------------------------------------------------- */ + +.app { + display: grid; + grid-template-columns: 232px minmax(0, 1fr); + min-height: 100vh; +} + +.app.compact { grid-template-columns: 72px minmax(0, 1fr); } + +.rail { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 12px 16px; + background: var(--ct-rail); + border-right: 1px solid var(--ct-line); + overflow-y: auto; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + height: 64px; + padding: 0 6px; + flex: none; +} + +.brand-mark { + width: 30px; + height: 30px; + flex: none; + border-radius: 8px; + background: var(--ct-accent); + color: var(--ct-on-accent); + display: flex; + align-items: center; + justify-content: center; +} + +.wordmark { + font: 700 18px var(--ct-font-display); + letter-spacing: .01em; + white-space: nowrap; +} + +.rail-nav { + display: flex; + flex-direction: column; + gap: 4px; +} + +.rail-spacer { flex-grow: 1; } + +.navbtn { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 44px; + padding: 0 14px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--ct-muted); + font: 500 14px var(--ct-font-body); + cursor: pointer; + position: relative; + text-align: left; + flex: none; +} + +.navbtn:hover { + background: var(--ct-surface); + color: var(--ct-text); + transform: none; +} + +.navbtn.on { + background: var(--ct-accent-dim); + color: var(--ct-text); +} + +.rail .navbtn.on::before { + content: ""; + position: absolute; + left: 0; + top: 13px; + width: 3px; + height: 18px; + border-radius: 2px; + background: var(--ct-accent); +} + +.navbtn svg { flex: none; } + +.navbtn:focus-visible, +.btn:focus-visible, +.chip:focus-within, +.combo-item:focus-visible, +.menu-item:focus-visible, +.picker-btn:focus-visible, +.team-card:focus-visible, +.history-row:focus-visible { + outline: 2px solid var(--ct-accent); + outline-offset: 2px; +} + +.status-card { + padding: 14px; + border-radius: 12px; + background: var(--ct-panel); + border: 1px solid var(--ct-line); + display: flex; + flex-direction: column; + gap: 4px; + flex: none; +} + +.status-card-row { + display: flex; + align-items: center; + gap: 9px; +} + +.status-dot { + width: 9px; + height: 9px; + flex: none; + border-radius: 50%; + background: var(--ct-faint); +} + +.status-dot[data-kind="success"] { background: var(--ct-success); } +.status-dot[data-kind="warning"] { background: var(--ct-warning); } +.status-dot[data-kind="danger"] { background: var(--ct-danger); } + +.status-title { font-weight: 600; } +.status-detail { font-size: 12px; color: var(--ct-muted); padding-left: 18px; } + +.app.compact .navlabel, +.app.compact .wordmark, +.app.compact .status-text { display: none; } +.app.compact .navbtn { justify-content: center; padding: 0; } +.app.compact .brand { justify-content: center; padding: 0; } +.app.compact .status-card { align-items: center; padding: 14px 0; } + +.main { + min-width: 0; + padding: 22px 36px 48px; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* --------------------------------------------------------------------------- + Header: page title + active combo picker + --------------------------------------------------------------------------- */ + +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 62px; +} + +.head-titles { display: flex; flex-direction: column; min-width: 0; } + +.overline { + font: 600 11px var(--ct-font-body); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--ct-muted); +} + +.head h1 { + margin: 0; + font: 700 30px var(--ct-font-display); + color: var(--ct-text); +} + +.picker { position: relative; } + +.picker-btn { + display: flex; + align-items: center; + gap: 12px; + min-width: 300px; + width: 100%; + padding: 8px 14px; + border-radius: 10px; + border: 1px solid var(--ct-line-strong); + background: var(--ct-panel); + color: var(--ct-text); + cursor: pointer; + text-align: left; + font-weight: 400; +} + +.picker-btn:hover { + background: var(--ct-surface); + transform: none; +} + +.picker-btn[aria-expanded="true"] { border-color: var(--ct-accent); } +.picker-btn svg { color: var(--ct-muted); flex: none; } + +.picker-text { + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; + margin-left: 6px; +} + +.picker-team { + font: 600 10.5px var(--ct-font-body); + letter-spacing: .08em; + color: var(--ct-muted); + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.picker-name { + font-weight: 600; + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.avatars { display: flex; flex: none; } +.avatars .avatar { margin-right: -6px; border: 2px solid var(--ct-panel); } + +.avatar { + width: 32px; + height: 32px; + flex: none; + border-radius: 50%; + background: var(--ct-surface2); + color: var(--ct-text); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + font: 700 12px var(--ct-font-display); +} + +.avatar img { width: 100%; height: 100%; object-fit: cover; } +.avatar-sm { width: 30px; height: 30px; font-size: 11px; } +.avatar-empty { background: var(--ct-surface); border-style: dashed !important; } + +.picker-menu { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 30; + min-width: 100%; + max-height: 380px; + overflow: auto; + padding: 6px; + border-radius: 10px; + border: 1px solid var(--ct-line-strong); + background: var(--ct-surface); + box-shadow: 0 16px 40px rgba(0, 0, 0, .5); +} + +.menu-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + width: 100%; + min-height: 44px; + padding: 8px 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ct-text); + font: 500 14px var(--ct-font-body); + cursor: pointer; + text-align: left; +} + +.menu-item:hover { background: var(--ct-hover); transform: none; } +.menu-item.on { color: var(--ct-accent); } +.menu-empty { padding: 10px 12px; margin: 0; } + +/* --------------------------------------------------------------------------- + Pages, cards, controls + --------------------------------------------------------------------------- */ + +.page { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +.card { + padding: 20px; + border-radius: 14px; + background: var(--ct-panel); + border: 1px solid var(--ct-line); + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.card-title, +.card h2.card-title { + margin: 0; + padding: 0; + border: 0; + font: 700 18px var(--ct-font-display); + color: var(--ct-text); +} + +.card p { margin: 0; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + padding: 0 16px; + border-radius: 8px; + border: 1px solid var(--ct-line-strong); + background: var(--ct-surface); + color: var(--ct-text); + font: 500 14px var(--ct-font-body); + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} + +.btn:hover { background: var(--ct-hover); color: var(--ct-text); transform: none; } +.btn:disabled { opacity: .45; cursor: default; } +.btn.primary { background: var(--ct-accent); border-color: var(--ct-accent); color: var(--ct-on-accent); font-weight: 600; } +.btn.primary:hover { background: var(--ct-accent-hover); } +.btn.danger { color: var(--ct-danger); border-color: #4A2328; background: transparent; } +.btn.danger:hover { background: rgba(240, 90, 99, .12); } +.btn.ghost { background: transparent; border-color: transparent; color: var(--ct-muted); } +.btn.ghost:hover { color: var(--ct-text); background: var(--ct-surface); } +.btn.icon-only { padding: 0 12px; } + +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.field > label, +.field label.label-with-input-help { + text-align: left; + padding: 0; + font: 600 11px var(--ct-font-body); + letter-spacing: .08em; + text-transform: uppercase; + color: var(--ct-muted); +} + +.field label.label-with-input-help { justify-content: flex-start; } + +.input, +.app .ww-char-editor input[type="text"], +.app .ww-char-picker-trigger { + min-height: 40px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--ct-line-strong); + background: var(--ct-bg); + color: var(--ct-text); + font: 400 14px var(--ct-font-body); + width: 100%; +} + +.input:focus, +.app .ww-char-editor input[type="text"]:focus { + border-color: var(--ct-accent); + box-shadow: 0 0 0 2px rgba(46, 197, 234, .2); +} + +select.input { padding-right: 8px; } + +.inline-field { + display: flex; + align-items: center; + gap: 10px; +} + +.formgrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; +} + +.form-actions-right { display: flex; gap: 8px; flex-wrap: wrap; } +.button-wrap { display: flex; flex-wrap: wrap; gap: 8px; } +.page-tools { display: flex; justify-content: flex-end; } + +.keycap { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 7px; + border-radius: 7px; + border: 1px solid var(--ct-line-strong); + border-bottom-width: 3px; + background: var(--ct-surface); + font: 600 12px var(--ct-font-mono); + color: var(--ct-text); + flex: none; +} + +.keycap-sm { min-width: 24px; height: 24px; font-size: 11px; padding: 0 5px; } + +/* Switch chips (Record / Replay) wrap a real checkbox */ +.chips { display: flex; flex-wrap: wrap; gap: 8px; } + +.chip { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 38px; + padding: 0 14px 0 12px; + border-radius: 8px; + border: 1px solid var(--ct-line-strong); + background: transparent; + color: var(--ct-muted); + font: 500 13px var(--ct-font-body); + cursor: pointer; + user-select: none; + text-align: left; +} + +.chip-input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; } +.chip .sw { width: 26px; height: 14px; border-radius: 7px; background: var(--ct-line-strong); position: relative; flex: none; } +.chip .sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 50%; background: var(--ct-muted); transition: left .15s ease; } +.chip:has(.chip-input:checked) { border-color: var(--ct-accent); color: var(--ct-text); background: var(--ct-accent-dim); } +.chip:has(.chip-input:checked) .sw { background: var(--ct-accent); } +.chip:has(.chip-input:checked) .sw::after { left: 14px; background: var(--ct-on-accent); } + +/* Toggles outside the timeline use the accent color */ +.card .toggle-switch:checked { + background: rgba(46, 197, 234, .3); + border-color: rgba(46, 197, 234, .6); +} + +.toast { + position: fixed; + left: 50%; + bottom: 32px; + transform: translateX(-50%); + z-index: 40; + padding: 10px 16px; + border-radius: 10px; + background: var(--ct-surface2); + border: 1px solid var(--ct-line-strong); + font-weight: 600; + box-shadow: 0 12px 30px rgba(0, 0, 0, .45); + max-width: calc(100vw - 32px); +} + +/* --------------------------------------------------------------------------- + Practice + --------------------------------------------------------------------------- */ + +.stat-cards { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; +} + +.stat-card { + padding: 14px 16px; + border-radius: 12px; + background: var(--ct-panel); + border: 1px solid var(--ct-line); +} + +.stat-label { + font: 600 11px var(--ct-font-body); + letter-spacing: .08em; + color: var(--ct-muted); +} + +.stat-value { font: 700 22px var(--ct-font-display); } +.stat-card:not([class*="diff-"]) .stat-value { color: var(--ct-text); } + +/* The Combo Steps panel keeps its black OBS look; only the panel corners match the new cards. */ +.app .timeline-section { + margin: 0; + border-radius: 14px; + border: 1px solid var(--ct-line); + box-shadow: none; +} + +.app .timeline-section .combo-steps-header { flex-wrap: wrap; } +.app .timeline-section .combo-steps-controls { flex-wrap: wrap; row-gap: 6px; } + +.attempt-card .attempt-header { margin: 0; flex-wrap: wrap; } +.attempt-card .results-table { + max-height: 360px; + overflow-y: auto; + border: 1px solid var(--ct-line); + border-radius: 10px; +} +.attempt-card .table-header { background: var(--ct-surface); color: var(--ct-muted); font-size: 12.5px; } +.attempt-card .result-row { border-top-color: var(--ct-line); } +.attempt-card .result-row:hover { background: var(--ct-surface); } + +/* --------------------------------------------------------------------------- + Combos + --------------------------------------------------------------------------- */ + +.editor { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.editor-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; } + +.combo-list-card { padding: 10px; gap: 4px; position: sticky; top: 22px; max-height: calc(100vh - 44px); } +.combo-list-tools { display: flex; gap: 8px; padding: 4px 4px 8px; } +.combo-list-tools .input { flex-grow: 1; min-width: 0; width: auto; } +.combo-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; } + +.combo-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-height: 44px; + padding: 8px 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ct-text); + font: 500 14px var(--ct-font-body); + cursor: pointer; + text-align: left; +} + +.combo-item:hover { background: var(--ct-surface); transform: none; } +.combo-item.on { background: var(--ct-accent-dim); } +.combo-item-text { display: flex; flex-direction: column; min-width: 0; } +.combo-item-text > span:first-child { overflow: hidden; text-overflow: ellipsis; } +.combo-item-steps { font: 400 12px var(--ct-font-mono); color: var(--ct-muted); white-space: nowrap; } + +.app .combo-input-wrap .combo-input-highlight, +.app .combo-input-wrap #comboInputs { + padding: 10px 12px; + font: 400 13px/1.6 var(--ct-font-mono); + border-radius: 8px; +} + +/* The highlighter sits under a transparent textarea, so the dark fill goes on the wrapper. */ +.app .combo-input-wrap { background: var(--ct-bg); border-radius: 8px; } + +.app .combo-input-wrap #comboInputs { + border: 1px solid var(--ct-line-strong); + min-height: 110px; +} + +.app .combo-input-wrap #comboInputs:focus { border-color: var(--ct-accent); box-shadow: 0 0 0 2px rgba(46, 197, 234, .2); } + +.reads-as { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.reads-as-label { font-size: 12px; color: var(--ct-muted); margin-right: 4px; } +.reads-as-items { display: contents; } +.reads-as-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + border-radius: 8px; + background: var(--ct-surface); + font-size: 12.5px; +} + +.reads-as-more { min-height: 30px; padding: 0 10px; font-size: 12.5px; } + +.app .demo-video-embed-wrap { margin: 0; } +.app .demo-video-embed-wrap iframe { max-width: 100%; border-radius: 10px; } + +.app .input-help-tooltip { text-transform: none; letter-spacing: normal; font-weight: 400; } + +/* --------------------------------------------------------------------------- + Teams + --------------------------------------------------------------------------- */ + +.cards3 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.team-card { + padding: 16px; + gap: 6px; + color: var(--ct-text); + font: 400 14px var(--ct-font-body); + text-align: left; + cursor: pointer; +} + +.team-card:hover { background: var(--ct-surface); transform: none; border-color: var(--ct-line-strong); } +.team-card.on { border-color: var(--ct-accent); } +.team-card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 4px; } +.team-card-name { font: 700 17px var(--ct-font-display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.team-slot { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 10px; + background: var(--ct-surface); + font-weight: 500; +} + +.app .ww-slot-row { + border-color: var(--ct-line); + border-radius: 10px; + background: var(--ct-surface); +} + +.app .ww-slot-row.ww-drag-over { border-color: var(--ct-accent); box-shadow: 0 0 0 2px rgba(46, 197, 234, .3); } +.app .ww-slot-portrait { border-radius: 50%; border-color: var(--ct-line-strong); width: 40px; height: 40px; flex-basis: 40px; } +.app .ww-slot-char-select { width: auto; } + +.app .ww-char-picker { flex: 1 1 200px; max-width: 280px; } +.app .ww-char-picker-trigger { display: flex; align-items: center; gap: 10px; font-weight: 500; } +.app .ww-char-picker-trigger:hover { background: var(--ct-surface); transform: none; } +.app .ww-char-picker-menu { background: var(--ct-surface); border-color: var(--ct-line-strong); border-radius: 10px; } +.app .ww-char-picker-option { background: transparent; color: var(--ct-text); font-weight: 500; } +.app .ww-char-picker-option:hover { background: var(--ct-hover); transform: none; } +.app .ww-char-sync { margin: 0; } +.app .ww-char-sync .input { flex: 1 1 200px; width: auto; } +.app .ww-ability-label { color: var(--ct-muted); font-size: 12.5px; } + +/* Character editor buttons are built by JS without classes; match .btn */ +.app .ww-char-btn-row button { + min-height: 38px; + padding: 0 16px; + border-radius: 8px; + border: 1px solid var(--ct-accent); + background: var(--ct-accent); + color: var(--ct-on-accent); + font: 600 14px var(--ct-font-body); +} + +.app .ww-char-btn-row button:hover { background: var(--ct-accent-hover); transform: none; } +.app .ww-char-btn-row button.danger { color: var(--ct-danger); border-color: #4A2328; background: transparent; } +.app .ww-char-btn-row button.danger:hover { background: rgba(240, 90, 99, .12); } +.app .ww-char-btn-row button:disabled { opacity: .45; cursor: default; } + +.app .ww-dash-panel { padding-top: 12px; border-top: 1px solid var(--ct-line); } + +/* --------------------------------------------------------------------------- + Characters (embedded page) + --------------------------------------------------------------------------- */ + +.page-fill { flex: 1; } + +.characters-frame { + width: 100%; + flex: 1; + min-height: calc(100vh - 150px); + border: 1px solid var(--ct-line); + border-radius: 14px; + background: var(--ct-panel); +} + +/* --------------------------------------------------------------------------- + History + --------------------------------------------------------------------------- */ + +.history-body { display: flex; flex-direction: column; gap: 4px; } + +.history-row { + display: grid; + grid-template-columns: minmax(0, 2fr) repeat(4, minmax(0, 1fr)); + gap: 12px; + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: 8px; + background: var(--ct-surface); + color: var(--ct-text); + font: 400 14px var(--ct-font-body); + text-align: left; + cursor: pointer; +} + +.history-row:hover { background: var(--ct-hover); transform: none; } +.history-row.on { box-shadow: inset 3px 0 0 var(--ct-accent); } +.history-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.history-head { + background: transparent; + cursor: default; + padding: 0 12px 4px; + font: 600 11px var(--ct-font-body); + letter-spacing: .08em; + text-transform: uppercase; + color: var(--ct-muted); +} + +/* --------------------------------------------------------------------------- + Settings + --------------------------------------------------------------------------- */ + +.settings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: start; +} + +.url-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px 6px 12px; + border-radius: 8px; + background: var(--ct-surface); +} + +.url-text { + flex: 1; + min-width: 0; + font: 400 12.5px var(--ct-font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* --------------------------------------------------------------------------- + Phone: the rail becomes a bottom tab bar + --------------------------------------------------------------------------- */ + +.bottomnav { display: none; } + +@media (max-width: 1100px) { + .cards3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 900px) { + .app, + .app.compact { grid-template-columns: minmax(0, 1fr); } + .rail { display: none; } + + .bottomnav { + display: flex; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 20; + background: var(--ct-rail); + border-top: 1px solid var(--ct-line); + padding: 6px 6px calc(6px + env(safe-area-inset-bottom)); + } + + .bottomnav .navbtn { + flex: 1; + flex-direction: column; + gap: 2px; + height: 52px; + justify-content: center; + padding: 0; + font-size: 11px; + text-align: center; + } + + .main { padding: 16px 16px 96px; } + .head { flex-direction: column; align-items: stretch; } + .picker-btn { min-width: 0; } + .editor, + .formgrid, + .cards3, + .settings { grid-template-columns: minmax(0, 1fr); } + .combo-list-card { position: static; max-height: 320px; } + .history-row { grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr)); } + .hide-small { display: none !important; } + .toast { bottom: 90px; } +} + +/* --------------------------------------------------------------------------- + OBS timeline view (?view=timeline): only the Combo Steps panel, as before + --------------------------------------------------------------------------- */ + +body.timeline-window-view { background: var(--bg-primary); } +body.timeline-window-view .app { display: block; } +body.timeline-window-view .rail, +body.timeline-window-view .head, +body.timeline-window-view .bottomnav, +body.timeline-window-view .toast, +body.timeline-window-view .page[data-page="practice"] > :not(.timeline-section) { display: none !important; } +body.timeline-window-view .main { padding: 20px; } +body.timeline-window-view .app .timeline-section { border: 0; border-radius: 0; } +body.timeline-window-view #openTimelineWindowBtn { display: none !important; } diff --git a/static/ws.js b/static/ws.js new file mode 100644 index 0000000..419399f --- /dev/null +++ b/static/ws.js @@ -0,0 +1,57 @@ +// Shared backend connection for every page (tracker, Characters, key overlay). +// connectTracker({ onMessage, onOpen, onClose }) opens ws://localhost:8765 and reconnects with backoff, +// so pages and OBS overlays recover on their own after the backend restarts. +(function () { + const WS_URL = 'ws://localhost:8765'; + const RECONNECT_START_MS = 500; + const RECONNECT_MAX_MS = 5000; + + function connectTracker(handlers) { + const h = handlers || {}; + const conn = { + socket: null, + /** Send { type, ...payload }; dropped quietly while disconnected. */ + send(type, payload = {}) { + const s = conn.socket; + if (!s || s.readyState !== WebSocket.OPEN) return false; + s.send(JSON.stringify({ type, ...payload })); + return true; + }, + isOpen() { + return !!conn.socket && conn.socket.readyState === WebSocket.OPEN; + }, + }; + let delayMs = RECONNECT_START_MS; + + function open() { + let socket; + try { + socket = new WebSocket(WS_URL); + } catch (_) { + setTimeout(open, delayMs); + return; + } + conn.socket = socket; + socket.onopen = () => { + delayMs = RECONNECT_START_MS; + if (h.onOpen) h.onOpen(); + }; + socket.onclose = () => { + if (conn.socket !== socket) return; + if (h.onClose) h.onClose(delayMs); + setTimeout(open, delayMs); + delayMs = Math.min(delayMs * 2, RECONNECT_MAX_MS); + }; + socket.onmessage = (event) => { + let msg; + try { msg = JSON.parse(event.data); } catch (_) { return; } + if (h.onMessage) h.onMessage(msg); + }; + } + + open(); + return conn; + } + + window.connectTracker = connectTracker; +})(); diff --git a/tests/test_combo_overview.py b/tests/test_combo_overview.py new file mode 100644 index 0000000..57c0ff9 --- /dev/null +++ b/tests/test_combo_overview.py @@ -0,0 +1,62 @@ +import unittest + +import combo_engine_ui as ui +from combo_engine import ComboTrackerEngine +from state_store import MemoryStateStore + + +def _engine_with_two_combos(): + engine = ComboTrackerEngine(state_store=MemoryStateStore()) + for name, inputs in (("B", "r, r, r"), ("A", "e, q")): + engine.new_combo() + ok, err = engine.save_or_update_combo( + name=name, + inputs=inputs, + enders="", + expected_time="1.5s" if name == "A" else "", + user_difficulty="", + step_display_mode="", + key_images=None, + demo_video="", + ww_team_id="", + ) + assert ok, err + return engine + + +class ComboOverviewTests(unittest.TestCase): + """The History page and the header combo picker read one row per combo from this.""" + + def test_one_row_per_combo_sorted_by_name(self): + engine = _engine_with_two_combos() + rows = ui.combo_overview(engine) + self.assertEqual([r["name"] for r in rows], ["A", "B"]) + self.assertEqual(rows[0]["steps"], 2) + self.assertEqual(rows[0]["target_ms"], 1500) + self.assertEqual(rows[1]["target_ms"], None) + + def test_counts_and_average_come_from_saved_stats(self): + engine = _engine_with_two_combos() + engine.combo_stats["A"].update(success=2, fail=3, best_ms=900, total_success_ms=2000) + row = ui.combo_overview(engine)[0] + self.assertEqual((row["success"], row["fail"], row["best_ms"], row["avg_ms"]), (2, 3, 900, 1000)) + self.assertIsNone(ui.combo_overview(engine)[1]["avg_ms"]) + + def test_team_assignment_is_reported(self): + engine = _engine_with_two_combos() + engine.ww.combo_ww_team["B"] = "team1" + rows = {r["name"]: r for r in ui.combo_overview(engine)} + self.assertEqual(rows["B"]["team_id"], "team1") + self.assertEqual(rows["A"]["team_id"], "") + + def test_init_and_stat_update_carry_the_overview(self): + engine = _engine_with_two_combos() + self.assertEqual(len(engine.init_payload()["overview"]), 2) + msg = engine.stat_update_payload() + self.assertEqual(msg["type"], "stat_update") + self.assertIn("stats", msg) + self.assertEqual(len(msg["overview"]), 2) + + +if __name__ == "__main__": + unittest.main() From 64af4818b25e9020e86ea431fc07be5d04ea1b46 Mon Sep 17 00:00:00 2001 From: WXP Date: Wed, 30 Sep 2026 22:20:02 -0400 Subject: [PATCH 3/7] Clean up dead code and drop engine pass-through methods - Stop tracking combos.json (personal data; combos.example.json seeds it) - Ignore .pytest_cache/ - Merge PyInstaller into requirements.txt; drop requirements-build.txt - Remove the built redesign mockup from docs/redesign/ - Web UI: remove the old key-image editor and multi-game leftovers, an unused team-image helper, and ~70 unused style.css rules - Python: remove ComboTrackerEngine's 25 one-line pass-through methods; callers now use combo_engine_ui, combo_analytics, format_utils, stats_recording, step_introspection and parser directly - Remove unused helpers: _step_accepts_input, combo_avg_ms, and input_normalization.split_inputs --- .gitignore | 3 + _combo_commands.py | 20 +- combo_engine.py | 126 +---- combo_engine_ui.py | 18 +- combos.json | 766 ------------------------------ docs/redesign/mockup-Main.dc.html | 594 ----------------------- docs/redesign/mockup-canvas.json | 1 - input_normalization.py | 9 +- requirements-build.txt | 2 - requirements.txt | 1 + static/app/combos.js | 197 +------- static/app/practice.js | 2 +- static/app/teams.js | 47 +- static/app/timeline.js | 1 - static/style.css | 440 +---------------- stats_recording.py | 14 +- tests/test_combo_overview.py | 2 +- ui_server.py | 7 +- 18 files changed, 75 insertions(+), 2175 deletions(-) delete mode 100644 combos.json delete mode 100644 docs/redesign/mockup-Main.dc.html delete mode 100644 docs/redesign/mockup-canvas.json delete mode 100644 requirements-build.txt diff --git a/.gitignore b/.gitignore index ac30faa..f5705de 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ ww_library_cache/ # Your saved combos, teams and stats (combos.example.json seeds it on first run) combos.json + +# pytest +.pytest_cache/ diff --git a/_combo_commands.py b/_combo_commands.py index cf8dcf9..2d3ca96 100644 --- a/_combo_commands.py +++ b/_combo_commands.py @@ -7,6 +7,10 @@ from typing import Any +import combo_engine_ui as ui +import format_utils +import stats_recording +from parser import split_inputs from persistence import fresh_combo_stats @@ -33,7 +37,7 @@ def apply_enders_from_text(engine, raw: str) -> tuple[bool, str | None]: parsed: dict[str, int] = {} soft_keys: set[str] = set() - for token in engine.split_inputs(raw): + for token in split_inputs(raw): t = token.strip() if not t: continue @@ -102,7 +106,7 @@ def save_or_update_combo( expected_ms = None expected_raw = (expected_time or "").strip() if expected_raw: - expected_ms = engine._parse_expected_time_ms(expected_raw) + expected_ms = format_utils.parse_expected_time_ms(expected_raw) if expected_ms is None: return False, "Invalid Expected time. Examples: 1.05s or 1050ms" @@ -116,7 +120,7 @@ def save_or_update_combo( if not (0.0 <= user_diff_val <= 10.0): return False, "Invalid Your difficulty. Use a number from 0 to 10." - input_list = [k.strip().lower() for k in engine.split_inputs(keys_str) if k.strip()] + input_list = [k.strip().lower() for k in split_inputs(keys_str) if k.strip()] if not input_list: return False, "Please provide at least one input." @@ -176,7 +180,7 @@ def save_or_update_combo( engine.ww.apply_combo_team_assignment(name, ww_team_id=ww_team_id) - engine._ensure_combo_stats(name) + stats_recording.ensure_combo_stats(engine, name) engine.set_active_combo(name, emit=False) engine.save_combos() @@ -218,8 +222,8 @@ def clear_history_and_stats(engine) -> None: engine.combo_stats[engine.active_combo_name] = fresh_combo_stats() engine.save_combos() engine._send({"type": "clear_results"}) - engine._send(engine.stat_update_payload()) - engine._send({"type": "fail_update", "fail_by_step": engine.failures_by_step()}) + engine._send(ui.stat_update_payload(engine)) + engine._send({"type": "fail_update", "fail_by_step": ui.failures_by_step(engine)}) engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) st = engine.get_status() engine._send({"type": "status", "text": st.text, "color": st.color}) @@ -232,8 +236,8 @@ def clear_all_history_and_stats(engine) -> None: engine.combo_stats[name] = fresh_combo_stats() engine.save_combos() engine._send({"type": "clear_results"}) - engine._send(engine.stat_update_payload()) - engine._send({"type": "fail_update", "fail_by_step": engine.failures_by_step()}) + engine._send(ui.stat_update_payload(engine)) + engine._send({"type": "fail_update", "fail_by_step": ui.failures_by_step(engine)}) engine._send({"type": "timeline_update", "steps": engine.timeline_steps()}) st = engine.get_status() engine._send({"type": "status", "text": st.text, "color": st.color}) diff --git a/combo_engine.py b/combo_engine.py index 8f57fe8..97369e1 100644 --- a/combo_engine.py +++ b/combo_engine.py @@ -25,8 +25,7 @@ from state_store import JsonStateStore, StateStore import _combo_commands as combo_commands -import input_normalization -from parser import expanded_ast_from_tokens, runtime_source_token_indices_from_tokens +from parser import expanded_ast_from_tokens, runtime_source_token_indices_from_tokens, split_inputs import format_utils import stats_recording import step_introspection @@ -227,39 +226,12 @@ def _send(self, msg: dict[str, Any]): def _emit_stats_and_fail(self): self._send(ui.stat_update_payload(self)) - self._send({"type": "fail_update", "fail_by_step": self.failures_by_step()}) + self._send({"type": "fail_update", "fail_by_step": ui.failures_by_step(self)}) # ------------------------- # Normalization helpers (delegate to input_normalization) # ------------------------- - def normalize_key(self, key) -> str: - return input_normalization.normalize_key(key) - - def normalize_mouse(self, button) -> str: - return input_normalization.normalize_mouse(button) - - def split_inputs(self, keys_str: str): - return input_normalization.split_inputs(keys_str or "") - - def _format_ms(self, ms: int) -> str: - return format_utils.format_ms(ms) - - def _format_ms_brief(self, ms: float | int | None) -> str: - return format_utils.format_ms_brief(ms) - - def _format_hold_requirement(self, hold_ms: int) -> str: - return format_utils.format_hold_requirement(hold_ms) - - def _expected_label_for_step(self, step: Any) -> str: - return step_introspection.expected_label_for_step(step) - - def _start_keys_for_step(self, step: Any) -> set[str]: - return step_introspection.start_keys_for_step(step) - - def _step_accepts_input(self, step: Any, input_name: str) -> bool: - return step_introspection.step_accepts_input(step, input_name) - def _mark_step(self, step_index: int, mark: str): """ Set a per-attempt mark for a step (for UI coloring). @@ -329,54 +301,6 @@ def save_combos(self) -> bool: # Stats helpers # ------------------------- - def _ensure_combo_stats(self, name: str) -> None: - stats_recording.ensure_combo_stats(self, name) - - def _format_percent(self, success: int, fail: int) -> str: - return stats_recording.format_percent(success, fail) - - def stats_text(self): - return ui.stats_text(self) - - def stat_update_payload(self) -> dict[str, Any]: - return ui.stat_update_payload(self) - - def failures_by_step(self) -> dict[str, int]: - return ui.failures_by_step(self) - - def failures_by_reason(self) -> dict[str, int]: - return ui.failures_by_reason(self) - - def min_time_text(self) -> str: - return ui.min_time_text(self) - - def _parse_expected_time_ms(self, raw: str | None) -> int | None: - return format_utils.parse_expected_time_ms(raw) - - def practical_apm(self) -> float | None: - return combo_analytics.practical_apm(self) - - def theoretical_max_apm(self) -> float | None: - return ui.theoretical_max_apm(self) - - def apm_text(self) -> str: - return ui.apm_text(self) - - def apm_max_text(self) -> str: - return ui.apm_max_text(self) - - def difficulty_score_10(self) -> float | None: - return combo_analytics.difficulty_score_10(self) - - def difficulty_text(self) -> str: - return ui.difficulty_text(self) - - def user_difficulty_value(self) -> float | None: - return ui.user_difficulty_value(self) - - def user_difficulty_text(self) -> str: - return ui.user_difficulty_text(self) - # ------------------------- # UI state snapshots # ------------------------- @@ -645,7 +569,7 @@ def send_transcription_result(self, transcript: str) -> None: """Push transcript to client: inputs field + parsed steps / timeline (used for live updates and when recording stops).""" with self._lock: self._send({"type": "transcription_result", "inputs": transcript or ""}) - tokens = [k.strip().lower() for k in self.split_inputs(transcript or "") if k.strip()] + tokens = [k.strip().lower() for k in split_inputs(transcript or "") if k.strip()] ast_list = expanded_ast_from_tokens(tokens) self.active_combo_tokens = tokens self.runtime_steps = [build_runtime_state(node) for node in ast_list] @@ -683,7 +607,7 @@ def set_active_combo(self, name: str, *, emit: bool = True): # Build runtime state objects from AST (expanded: wait(r,t) -> press + wait) ast_list = expanded_ast_from_tokens(self.active_combo_tokens) self.runtime_steps = [build_runtime_state(node) for node in ast_list] - self._ensure_combo_stats(name) + stats_recording.ensure_combo_stats(self, name) # Restore saved WW active team when selecting a combo self.ww.ww_active_team_id = self.ww.combo_ww_team.get(name) @@ -694,23 +618,23 @@ def set_active_combo(self, name: str, *, emit: bool = True): if emit: st = self.get_status() self._send({"type": "combo_data", **self.get_editor_payload()}) - self._send({"type": "min_time", "text": self.min_time_text()}) + self._send({"type": "min_time", "text": ui.min_time_text(self)}) self._send( { "type": "difficulty_update", - "text": self.difficulty_text(), - "value": self.difficulty_score_10(), + "text": ui.difficulty_text(self), + "value": combo_analytics.difficulty_score_10(self), } ) self._send( { "type": "user_difficulty_update", - "text": self.user_difficulty_text(), - "value": self.user_difficulty_value(), + "text": ui.user_difficulty_text(self), + "value": ui.user_difficulty_value(self), } ) - self._send({"type": "apm_update", "text": self.apm_text()}) - self._send({"type": "apm_max_update", "text": self.apm_max_text()}) + self._send({"type": "apm_update", "text": ui.apm_text(self)}) + self._send({"type": "apm_max_update", "text": ui.apm_max_text(self)}) self._emit_stats_and_fail() self._send({"type": "timeline_update", "steps": self.timeline_steps()}) self._send({"type": "status", "text": st.text, "color": st.color}) @@ -743,10 +667,6 @@ def reset_tracking(self): self._reset_wait_state() self._reset_group_state() - def _active_step(self): - """Current step (StepState). Alias for _active_runtime_step for backward compatibility.""" - return self._active_runtime_step() - def _active_runtime_step(self): """Current step as StepState (for new match dispatch).""" if 0 <= self.current_index < len(self.runtime_steps): @@ -1084,7 +1004,7 @@ def _fail_combo(self, reason: str, now: float, *, expected_label: str | None = N self.record_combo_fail( actual=actual or reason, expected_step_index=int(self.current_index), - expected_label=expected_label or self._expected_label_for_step(self._active_step()), + expected_label=expected_label or step_introspection.expected_label_for_step(self._active_runtime_step()), reason=reason, elapsed_ms=elapsed_ms, ) @@ -1110,7 +1030,7 @@ def _check_ender_fail(self, input_name: str, now: float) -> None: if not self._ender_can_drop_now(input_name, now=now): return self._mark_step(int(self.current_index), "missed") - expected = str(self._expected_label_for_step(self._active_runtime_step()) or "").strip().lower() + expected = str(step_introspection.expected_label_for_step(self._active_runtime_step()) or "").strip().lower() actual = str(input_name or "").strip().lower() if self.no_fail_mode: self._mark_current_and_skip(now, mark="missed") @@ -1156,7 +1076,7 @@ def _start_wait(self, required_ms: int, *, started_at: float): def _complete_wait(self, now: float, *, fail: bool, reason: str | None = None): required_ms = int(self.wait_required_ms or 0) waited_ms = max(0.0, (now - self.wait_started_at) * 1000) - req_s = self._format_hold_requirement(required_ms) if required_ms else "?" + req_s = format_utils.format_hold_requirement(required_ms) if required_ms else "?" # For display, include mode when relevant mode = "soft" step = self._active_runtime_step() @@ -1179,7 +1099,7 @@ def _complete_wait(self, now: float, *, fail: bool, reason: str | None = None): self.record_combo_fail( actual=str(reason or ""), expected_step_index=int(self.current_index), - expected_label=self._expected_label_for_step(self._active_runtime_step()), + expected_label=step_introspection.expected_label_for_step(self._active_runtime_step()), reason="too early", elapsed_ms=elapsed_ms, ) @@ -1229,7 +1149,7 @@ def _complete_hold(self, now: float, *, auto: bool): self._hold_max_held_ms = max(self._hold_max_held_ms, held_ms) ok = held_ms >= float(target_hold_ms) - req_s = self._format_hold_requirement(target_hold_ms) + req_s = format_utils.format_hold_requirement(target_hold_ms) total_ms = (now - self.start_time) * 1000 if self.start_time else 0.0 label = f"{target_input} (hold ≥ {req_s}, {held_ms:.0f}ms)" @@ -1268,7 +1188,7 @@ def _complete_hold(self, now: float, *, auto: bool): self.record_combo_fail( actual=f"released @ {held_ms:.0f}ms", expected_step_index=int(self.current_index), - expected_label=self._expected_label_for_step(step), + expected_label=step_introspection.expected_label_for_step(step), reason="hold incomplete", elapsed_ms=elapsed_ms, ) @@ -1577,7 +1497,7 @@ def _replay_accept_unlocked( result = step.process_release(key, now) if isinstance(result, AcceptResult) and result.advance: held_ms = (now - step.started_at) * 1000.0 if step.started_at else float(step.required_ms or 0) - req_s = self._format_hold_requirement(int(step.required_ms or 0)) + req_s = format_utils.format_hold_requirement(int(step.required_ms or 0)) label = f"{holder} (hold ≥ {req_s}, {held_ms:.0f}ms) [auto]" self.record_hit(label, step_ms, total_ms, at_time=now) self.last_input_time = now @@ -1628,7 +1548,7 @@ def _replay_accept_unlocked( # Completion marker (holder key-up callback from macro player). if not pressed and key == holder: held_ms = (now - self.hold_started_at) * 1000.0 if self.hold_started_at else float(step.required_ms or 0) - req_s = self._format_hold_requirement(int(step.required_ms or 0)) + req_s = format_utils.format_hold_requirement(int(step.required_ms or 0)) label = f"{holder} (hold ≥ {req_s}, {held_ms:.0f}ms) [auto]" self.record_hit(label, step_ms, total_ms, at_time=now) self.last_input_time = now @@ -1806,7 +1726,7 @@ def _process_press_unlocked( # Forgiving hold: only drop if they pressed the next step's key AND it's an ender (new press only). next_step = self.runtime_steps[int(self.current_index) + 1] if (int(self.current_index) + 1) < len(self.runtime_steps) else None - next_keys = self._start_keys_for_step(next_step) + next_keys = step_introspection.start_keys_for_step(next_step) if not press_is_repeat and next_keys and (input_name in next_keys) and self._ender_can_drop_now(input_name, now=now): if self.no_fail_mode: self._mark_current_and_skip(now) @@ -1890,7 +1810,7 @@ def _process_press_unlocked( next_idx = int(self.current_index) + 1 if next_idx < len(self.runtime_steps): next_step = self.runtime_steps[next_idx] - next_keys = self._start_keys_for_step(next_step) + next_keys = step_introspection.start_keys_for_step(next_step) if next_keys and input_name in next_keys: step.was_skipped = True step.completed = True @@ -1902,7 +1822,7 @@ def _process_press_unlocked( next_idx = int(self.current_index) + 1 if next_idx < len(self.runtime_steps): next_step = self.runtime_steps[next_idx] - next_keys = self._start_keys_for_step(next_step) + next_keys = step_introspection.start_keys_for_step(next_step) if next_keys and input_name in next_keys: step.was_skipped = True self._advance_step(now) @@ -1920,7 +1840,7 @@ def _process_press_unlocked( prev_step.was_skipped = False return if not press_is_repeat and self._ender_can_drop_now(input_name, now=now): - expected = self._expected_label_for_step(step) or "?" + expected = step_introspection.expected_label_for_step(step) or "?" self._mark_step(int(self.current_index), "missed") if self.no_fail_mode: self._mark_current_and_skip(now, mark="missed") diff --git a/combo_engine_ui.py b/combo_engine_ui.py index 5abf6ae..b3f57b2 100644 --- a/combo_engine_ui.py +++ b/combo_engine_ui.py @@ -6,6 +6,8 @@ from timeline_model import TimelineSteps import combo_analytics +import format_utils +import stats_recording from states import ( GroupState, HoldState, @@ -30,12 +32,12 @@ def stats_text(engine) -> str: summary = combo_analytics.combo_stats_summary(engine) s = summary["success"] f = summary["fail"] - pct = engine._format_percent(s, f) + pct = stats_recording.format_percent(s, f) best = summary["best_ms"] avg = summary["avg_ms"] return ( f"Stats: {s} success / {f} fail ({pct})" - f" | Best: {engine._format_ms_brief(best)} | Avg: {engine._format_ms_brief(avg)}" + f" | Best: {format_utils.format_ms_brief(best)} | Avg: {format_utils.format_ms_brief(avg)}" ) @@ -77,7 +79,7 @@ def min_time_text(engine) -> str: if not getattr(engine, "runtime_steps", None): return "Fastest possible: —" min_ms = combo_analytics.min_combo_time_ms(engine) - return f"Fastest possible: {engine._format_ms(min_ms)}" + return f"Fastest possible: {format_utils.format_ms(min_ms)}" def theoretical_max_apm(engine) -> float | None: @@ -140,7 +142,7 @@ def get_editor_payload(engine) -> dict[str, Any]: if name: ms = engine.combo_expected_ms.get(name) if ms is not None: - expected = engine._format_ms_brief(ms) + expected = format_utils.format_ms_brief(ms) user_diff = "" if name: d = engine.combo_user_difficulty.get(name) @@ -216,7 +218,7 @@ def get_status(engine) -> Status: if not engine.runtime_steps: return Status("Status: Select a combo to start", "neutral") - step = engine._active_step() + step = engine._active_runtime_step() if not step: return Status("Status: Select a combo to start", "neutral") @@ -240,10 +242,10 @@ def get_status(engine) -> Status: return Status("Ready! Press the first input to start.", "ready") if engine.wait_in_progress: - req = engine._format_hold_requirement(int(engine.wait_required_ms or 0)) + req = format_utils.format_hold_requirement(int(engine.wait_required_ms or 0)) mode = "soft" try: - s = engine._active_step() + s = engine._active_runtime_step() if isinstance(s, WaitState): mode = str(s.mode or "soft").strip().lower() or "soft" except Exception: @@ -252,7 +254,7 @@ def get_status(engine) -> Status: return Status(f"Animation lock ≥ {req} (inputs ignored)...", "wait") return Status(f"Waiting ≥ {req}...", "wait") if engine.hold_in_progress: - req = engine._format_hold_requirement(int(engine.hold_required_ms or 0)) + req = format_utils.format_hold_requirement(int(engine.hold_required_ms or 0)) inp = str(engine.hold_expected_input or "").upper() return Status(f"Holding '{inp}' (≥ {req}). Release OR press next input to continue...", "recording") return Status("Recording...", "recording") diff --git a/combos.json b/combos.json deleted file mode 100644 index 6181d25..0000000 --- a/combos.json +++ /dev/null @@ -1,766 +0,0 @@ -{ - "version": 1, - "last_active_combo": "Agusta, Iuno, Shorekeeper Double intro", - "no_fail_mode": true, - "macro_start_key": "f", - "macro_stop_key": "esc", - "macro_spam_interval_ms": 30, - "combos": { - "Zani 1": [ - "f", - "wait:0.18s", - "e", - "wait:0.1s", - "lmb", - "wait:0.1s", - "2", - "hold(e, 0.35s)", - "wait(r, 3.65s)" - ], - "Zani 3": [ - "hold(e, 0.3s)", - "3", - "hold(lmb, 0.3s)", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "2", - "e", - "rmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "rmb", - "wait:0.1s", - "hold(lmb, 0.30s)", - "1", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "lmb", - "3", - "wait(f, 1s)", - "1", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "lmb", - "wait(3, 1.22s)", - "[wait(r, 2s), e, q]", - "wait(2, 1.63s)", - "1", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "r" - ], - "Zani 2": [ - "e", - "3", - "[wait(r, 2s), q, e]", - "2", - "lmb", - "wait:0.1s", - "rmb", - "wait:0.5s", - "lmb", - "q", - "rmb", - "hold(lmb, 0.30)", - "rmb", - "wait:0.5s", - "lmb", - "wait(1, 0.5)", - "[wait(e, 0.5), q]", - "wait(r, 3.2s)" - ], - "Zani Pheobe Rover Rotation": [ - "f", - "wait:0.11s", - "e", - "wait:0.2s", - "lmb", - "wait:0.1s", - "2", - "hold(e, 0.45s)", - "wait(r, 3.7s)", - "e", - "wait:0.05s", - "3", - "[wait(r, 2s), q, e]", - "2", - "[lmb, wait:0.1s, rmb, wait:0.5s, lmb, q, rmb, hold(lmb, 0.30), rmb, wait:0.5s, lmb]", - "wait(1, 0.5)", - "[wait(e, 0.5), q]", - "wait(r, 3.2s)", - "hold(e, 0.3s)", - "3", - "hold(lmb, 0.3s)", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "2", - "e", - "rmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "rmb", - "wait:0.1s", - "hold(lmb, 0.30s)", - "1", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "lmb", - "3", - "wait(f, 1s)", - "1", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "lmb", - "wait(3, 1.22s)", - "[wait(r, 2s), e, q]", - "wait(2, 1.63s)", - "1", - "lmb", - "wait:0.1s", - "lmb", - "wait:0.1s", - "r" - ], - "Agusta, Iuno, Shorekeeper Double intro": [ - "f", - "wait:0.15s", - "e", - "wait:0.15s", - "2", - "e", - "wait:0.15s", - "3", - "[q, e, wait(r, 2.9s)]", - "2", - "[e, wait:0.05s, q]", - "3", - "lmb", - "wait:0.30s", - "lmb", - "wait:0.30s", - "lmb", - "wait:0.20s", - "rmb", - "wait:0.1s", - "lmb", - "wait:0.15s", - "hold(lmb, 0.1s)", - "1", - "hold(lmb, 0.25s)", - "lmb", - "wait:0.3s", - "2", - "space", - "wait:0.15s", - "3", - "lmb", - "wait:0.3s", - "lmb", - "wait:0.1s", - "wait(1, 0.9s)", - "hold(lmb, 0.25s)", - "lmb", - "wait:0.3s", - "2", - "-e", - "wait(r, 3.9s)", - "lmb", - "wait:0.50s", - "lmb", - "wait:0.70s", - "lmb", - "wait:0.50s", - "-e", - "wait:0.20s", - "hold(lmb, 0.10s)", - "wait(1, 0.9s)", - "hold(lmb, 0.25s)", - "lmb", - "wait:0.3s", - "[{e, wait:0.80s, e, wait:1.0s, e, wait:0.2s}, wait(r, 1.6s)]", - "hold(r, 0.2s)", - "wait(lmb, 4.1s)" - ], - "Aemeath, Lynae, Mornye": [ - "f", - "wait:0.2s", - "lmb", - "wait:0.4s", - "lmb", - "wait:0.5s", - "lmb", - "wait:0.9s", - "lmb", - "wait:0.1s", - "-q", - "wait:0.05s", - "2", - "wait:0.15s", - "e", - "wait:0.05s", - "3", - "wait:0.25s", - "lmb", - "wait:0.8s", - "lmb", - "wait:0.2s", - "2", - "wait(r, 4.25s)", - "3", - "hold(lmb, 1.35s)", - "wait(r, 4.8s)", - "hold(lmb, 1.55s)", - "e", - "wait:1s", - "hold(lmb, 1.75s)", - "wait(2, 0.8s)", - "hold(lmb, 1.7s)", - "wait:0.47s", - "space", - "wait:0.66s", - "space", - "wait:0.65s", - "space", - "wait:0.81s", - "lmb", - "wait:0.77s", - "q", - "wait:0.2s", - "wait(1, 0.9s)", - "lmb", - "wait:0.5s", - "lmb", - "wait:0.85s", - "lmb", - "wait:0.77s", - "wait(f, 1.75s)", - "wait(r, 4.2s)", - "wait(e, 2.8s)", - "lmb", - "wait:0.5s", - "lmb", - "wait:0.9s", - "lmb", - "wait:0.5s", - "lmb", - "wait:0.5s", - "wait(e, 3s)", - "hold(lmb, 1.1s)", - "wait(r, 4.2s)" - ], - "Agusta, Iuno, Shorekeeper Double vs monkey": [ - "f", - "wait:0.2s", - "e", - "wait:0.15s", - "2", - "e", - "wait:0.15s", - "3", - "[q, e, wait(r, 3s)]", - "lmb", - "wait:0.30s", - "lmb", - "wait:0.20s", - "rmb", - "wait:0.20s", - "lmb", - "2", - "[e, wait:0.05s, q]", - "3", - "lmb", - "wait:0.30s", - "lmb", - "wait:0.20s", - "rmb", - "wait:0.20s", - "lmb", - "1", - "hold(lmb, 0.25s)", - "lmb", - "wait:0.3s", - "2", - "space", - "wait:0.15s", - "3", - "lmb", - "wait:0.20s", - "wait(1, 0.9s)", - "hold(lmb, 0.25s)", - "lmb", - "wait:0.3s", - "2", - "-e", - "wait(r, 3.9s)", - "lmb", - "wait:0.50s", - "lmb", - "wait:0.70s", - "lmb", - "wait:0.50s", - "-e", - "wait:0.20s", - "hold(lmb, 0.10s)", - "wait(1, 0.9s)", - "hold(lmb, 0.25s)", - "lmb", - "wait:0.3s", - "[{e, wait:0.80s, e, wait:1.0s, e, wait:0.2s}, wait(r, 1.6s)]", - "hold(r, 0.2s)", - "wait(lmb, 4.1s)" - ], - "test2": [ - "f", - "wait:0.1s", - "spam(lmb, 2.2s)", - "rmb", - "wait:0.1s", - "spam(lmb, 1.8s)", - "space", - "wait:0.1s", - "hold(e, 0.9s)", - "spam(lmb, 2.8s)", - "rmb", - "wait:0.1s", - "spam(lmb, 1.9s)", - "space", - "wait:0.1s", - "hold(e, 1s)", - "lmb", - "wait:0.17s", - "lmb", - "wait:0.177s", - "lmb", - "wait:0.155s", - "lmb", - "wait:0.153s", - "lmb", - "wait:0.16s", - "lmb", - "wait:0.167s", - "lmb", - "wait:0.153s", - "lmb", - "wait:0.14s", - "hold(lmb, 1.2s)", - "hold(r, 4s)" - ], - "Hiyuki Lucilla Suisui": [ - "f", - "wait:0.15s", - "hold(lmb, 2.3s)", - "e", - "hold(lmb, 2.8s)", - "e", - "hold(lmb, 1s)", - "1", - "hold(lmb, 1.4s)", - "e", - "wait:0.1s", - "3", - "wait(r, 4.4s)", - "wait(2, 0.1s)", - "hold(e, 1.5s)", - "q", - "wait:1.2s", - "wait(r, 4.4s)", - "hold(lmb, 0.55s)", - "1", - "hold(lmb, 1.05s)", - "2", - "hold(lmb, 3.5s)", - "1", - "hold(lmb, 2.9s)", - "wait(r, 3.95s)", - "lmb", - "wait:0.6s", - "lmb", - "wait:0.6s", - "lmb", - "wait:0.1s", - "rmb", - "wait:0.2s", - "lmb", - "wait:0.7s", - "lmb", - "wait:0.6s", - "lmb", - "wait:0.1s", - "space", - "hold(e, 0.8s)", - "spam(lmb, 1.2s)", - "wait(f, 1.75s)", - "lmb", - "wait:0.2s", - "rmb", - "wait:0.2s", - "lmb", - "wait:0.7s", - "lmb", - "wait:0.7s", - "lmb", - "wait:0.6s", - "space", - "wait:0.1s", - "hold(e, 0.8s)", - "spam(lmb, 1.2s)", - "hold(lmb, 2.8s)", - "wait(r, 3.95s)", - "q", - "e", - "hold(lmb, 0.5s)", - "wait(3, 0.5s)", - "hold(lmb, 3.5s)" - ] - }, - "combo_enders": { - "1": 1000, - "2": 1000, - "3": 1000, - "e": 2000, - "r": 2000, - "space": 2000 - }, - "combo_enders_soft": [ - "space" - ], - "transcribe_valid_keys": "f, e, q, r, lmb, rmb, 1, 2, 3, space, shift", - "transcribe_start_key": "f", - "transcribe_strip_wait_under_enabled": false, - "transcribe_strip_wait_under_ms": "0", - "combo_stats": { - "Zani 1": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "Zani 3": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "Zani 2": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "Zani Pheobe Rover Rotation": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "Agusta, Iuno, Shorekeeper Double intro": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "Aemeath, Lynae, Mornye": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "Agusta, Iuno, Shorekeeper Double vs monkey": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "test2": { - "success": 1, - "fail": 0, - "best_ms": 17585, - "total_success_ms": 17585, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - }, - "Hiyuki Lucilla Suisui": { - "success": 0, - "fail": 0, - "best_ms": null, - "total_success_ms": 0, - "fail_by_step": {}, - "fail_by_expected": {}, - "fail_by_reason": {}, - "fail_events": [] - } - }, - "combo_expected_ms": { - "Zani 1": 5000, - "Zani 3": 19000, - "Zani 2": 9000, - "Zani Pheobe Rover Rotation": 30000, - "Agusta, Iuno, Shorekeeper Double intro": 24000, - "Aemeath, Lynae, Mornye": 42500, - "Agusta, Iuno, Shorekeeper Double vs monkey": 21000 - }, - "combo_user_difficulty": { - "Zani 1": 8.0, - "Agusta, Iuno, Shorekeeper Double intro": 7.5, - "Aemeath, Lynae, Mornye": 7.5, - "Agusta, Iuno, Shorekeeper Double vs monkey": 8.0 - }, - "combo_step_display_mode": { - "Zani 3": "images", - "Zani 2": "images", - "Zani 1": "images", - "Zani Pheobe Rover Rotation": "images", - "Agusta, Iuno, Shorekeeper Double intro": "images", - "Aemeath, Lynae, Mornye": "images", - "Agusta, Iuno, Shorekeeper Double vs monkey": "images", - "test2": "images", - "Hiyuki Lucilla Suisui": "images" - }, - "combo_key_images": {}, - "combo_demo_video": { - "Aemeath, Lynae, Mornye": "https://youtu.be/goTBFZBsBTo" - }, - "ww_characters": { - "aemeath": { - "name": "Aemeath", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_53_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34025_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAimisiB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAimisiC1.png" - } - }, - "lynae": { - "name": "Lynae", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_60_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorGun.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34021_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconLinNaiB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconLinNaiC1.png" - } - }, - "mornye": { - "name": "Mornye", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_61_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorSword.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34022_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconMoNingB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconMoNingC1.png" - } - }, - "agusta": { - "name": "Agusta", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_51_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorSword.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34017_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAogusitaB3.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconAogusitaC2.png" - } - }, - "iuno": { - "name": "Iuno", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_48_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorFist.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34018_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconYounuoB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconYounuoC1.png" - } - }, - "shorekeeper": { - "name": "Shorekeeper", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_28_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_350_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconShouanrenB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconShouanrenC1.png" - } - }, - "hiyuki": { - "name": "Hiyuki", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_67_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34027_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeixueB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeixueC1.png" - } - }, - "chisa": { - "name": "Chisa", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_57_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorSword.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconQianxiaoC1.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconQianxiaoB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconQianxiaoC1.png" - } - }, - "zani": { - "name": "Zani", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_38_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorFist.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_32033_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZanniB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZanniC1.png" - } - }, - "pheobe": { - "name": "Pheobe", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_45_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_995_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeibiB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconFeibiC1.png" - } - }, - "spectro_rover": { - "name": "Spectro_Rover", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_5.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_350_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZhujueB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconZhujueC1.png" - } - }, - "lucilla": { - "name": "Lucilla", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_66_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34027_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_SkillIconLuoselaB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_SkillIconLuoselaC1.png" - } - }, - "suisui": { - "name": "Suisui", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_71_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorMagic.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34031_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconSuisuiB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconSuisuiC1.png" - } - }, - "yangyang xuanling": { - "name": "Yangyang Xuanling", - "swap_image": "https://wuthering.gg/_ipx/q_70&s_100x100/images/iconrolehead150/T_IconRoleHead150_70_UI.png", - "lmb_image": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconNorKnife.png", - "ability_images": { - "q": "https://wuthering.gg/_ipx/q_70&s_34x34/images/mstskill/T_MstSkil_34031_UI.png", - "e": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconXuanLingB1.png", - "r": "https://wuthering.gg/_ipx/q_70&s_32x32/images/iconskill/SP_IconXuanLingC1.png" - } - } - }, - "ww_dash_image": "\ud83d\udca8", - "ww_teams": { - "3fab25fc2a": { - "name": "Aemeath Lynae Mornye", - "slot1": "aemeath", - "slot2": "lynae", - "slot3": "mornye" - }, - "65d147f85c": { - "name": "Lynae Aemeth Mornye", - "slot1": "lynae", - "slot2": "aemeath", - "slot3": "mornye" - }, - "cc9555e2d9": { - "name": "Zani Phobe Rover", - "slot1": "zani", - "slot2": "pheobe", - "slot3": "spectro_rover" - }, - "a8e3fa33e9": { - "name": "Agusta Iuno Shorekeeper", - "slot1": "agusta", - "slot2": "iuno", - "slot3": "shorekeeper" - }, - "8f6a67b57d": { - "name": "Hiyuki Lucilla Suisui", - "slot1": "hiyuki", - "slot2": "lucilla", - "slot3": "suisui" - }, - "18d2a3510a": { - "name": "Xuanling, Chisa, SuiSui", - "slot1": "yangyang xuanling", - "slot2": "chisa", - "slot3": "suisui" - } - }, - "ww_active_team_id": "a8e3fa33e9", - "combo_ww_team": { - "Aemeath, Lynae, Mornye": "3fab25fc2a", - "Agusta, Iuno, Shorekeeper Double intro": "a8e3fa33e9", - "Agusta, Iuno, Shorekeeper Double vs monkey": "a8e3fa33e9", - "Zani 1": "cc9555e2d9", - "Zani 2": "cc9555e2d9", - "Zani 3": "cc9555e2d9", - "Zani Pheobe Rover Rotation": "cc9555e2d9", - "test2": "8f6a67b57d", - "Hiyuki Lucilla Suisui": "8f6a67b57d" - } -} \ No newline at end of file diff --git a/docs/redesign/mockup-Main.dc.html b/docs/redesign/mockup-Main.dc.html deleted file mode 100644 index 58ddcf2..0000000 --- a/docs/redesign/mockup-Main.dc.html +++ /dev/null @@ -1,594 +0,0 @@ - - - - -Combo Tracker redesign - - - - - - - - - - -
- - - - - -
- - -
-
- {{overline}} -

{{pageTitle}}

-
-
- - -
- - - -
-
-
-
- - - -
- -
- -
- {{s.label}} - {{s.value}} -
-
-
- -
-
-

Combo Steps

-
- - - -
-
- -

{{promptTitle}}

- -
- -
- {{s.corner}} - {{s.key}} - {{s.time}} - {{s.label}} -
-
-
- -
- - -
-
- -
-
-

Last attempt

- Step times in ms -
- -
- -
- {{l.key}} - {{l.ms}} -
-
-
-
- -

No attempts yet. Press F in game to start, or use Simulate next input to see how a run looks.

-
-
-
-
- - - -
-
-
- - - -
- - - -
- -
-
-
-
- - -
-
- - -
-
-
- - -
-
- Reads as - - {{s.key}}{{s.label}} - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
- - -
-
-
- -
-

Record or replay

-
- - -
-

Recording starts when you press F and stops on Esc. Macro replay starts on F8 and stops on F9 or Esc. Keys and delays are set in Settings.

-
-
-
-
- - - -
-
-
- -
-
-

{{t.name}}

- {{t.combos}} -
-
- -
- {{m.slot}} - {{m.initials}} - {{m.name}} -
-
-
-
-
-
-
-
- - - -
-

The Characters page you already have (moves from encore.moe, team rotations from AntoCrasher's sheet) moves in here as a page of the app, restyled to match.

-
- - - -
-
-
- - - -
-
- ComboSuccess / failBestAverage -
- -
- {{c.name}}0 / 0—— -
-
-

Your saved combos have no attempts logged yet, so every row is empty.

-
-
- - - -
-
-

Recording

-
-
-
-
-
-

Macro replay

-
-
-
-
-
-

Stream overlays

-

Add these as OBS Browser Sources. They keep their current look.

-
localhost:8080/?view=timeline
-
localhost:8080/keys.html
-
-
-

Data

-

Combos, teams and stats live in combos.json next to the app.

-
- - - -
-
-
-
-
- - - - - - -
{{toast}}
-
-
-
- - - diff --git a/docs/redesign/mockup-canvas.json b/docs/redesign/mockup-canvas.json deleted file mode 100644 index 1f7c8c7..0000000 --- a/docs/redesign/mockup-canvas.json +++ /dev/null @@ -1 +0,0 @@ -{"v":3,"createdOnFiles":{"v":1,"at":"2026-09-30T22:07:54Z"},"title":"Combo Tracker Redesign","launch":{"view":"focused","file":"Main.dc.html"},"pages":[],"boards":{"Main.dc.html":{"x":0,"y":0,"w":1320,"h":860,"title":"Combo Tracker (BetterGhub style)","expand":"fill","is_interactive":true}},"order":["Main.dc.html"],"notes":{},"designSystems":[]} diff --git a/input_normalization.py b/input_normalization.py index da112a1..6df60f6 100644 --- a/input_normalization.py +++ b/input_normalization.py @@ -1,11 +1,9 @@ """ -Input parsing and normalization: pynput key/mouse to internal tokens, split input strings. +Input normalization: pynput key/mouse events to internal tokens. """ from __future__ import annotations -from parser import split_inputs as _parser_split_inputs - def normalize_key(key) -> str: """ @@ -38,8 +36,3 @@ def normalize_mouse(button) -> str: if button == mouse.Button.middle: return "mmb" return "mouse_extra" - - -def split_inputs(keys_str: str): - """Split user-entered Inputs string into top-level comma-separated tokens.""" - return _parser_split_inputs(keys_str or "") diff --git a/requirements-build.txt b/requirements-build.txt deleted file mode 100644 index c010b52..0000000 --- a/requirements-build.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Optional: only needed to build the Windows release executable -pyinstaller>=6.0 diff --git a/requirements.txt b/requirements.txt index ff86f89..773e40b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pynput==1.8.1 websockets==15.0.1 +pyinstaller>=6.0 \ No newline at end of file diff --git a/static/app/combos.js b/static/app/combos.js index 72b02ec..8135295 100644 --- a/static/app/combos.js +++ b/static/app/combos.js @@ -25,8 +25,6 @@ function initializeUI(data) { appState.targetGame = preserved.targetGame; appState.wwTeamId = preserved.wwTeamId; appState.stepDisplayMode = preserved.stepDisplayMode; - const gameSelect = getEl('targetGameSelect'); - if (gameSelect) gameSelect.value = appState.targetGame; const teamSelect = getEl('wwTeamSelect'); if (teamSelect) teamSelect.value = appState.wwTeamId; const stepToggle = getEl('stepDisplayToggle'); @@ -40,7 +38,7 @@ function initializeUI(data) { const collapseChainsToggle = getEl('collapseChainsToggle'); if (collapseChainsToggle) collapseChainsToggle.checked = preserved.collapseChainedPresses; appState.keyImages = preserved.keyImages; - syncGameUIVisibility(); + renderWwPanels(); refreshTimelineIfLoaded(); } if (data.status) updateStatus(data.status.text, data.status.color); @@ -81,175 +79,6 @@ function initializeUI(data) { } } -// Split a combo string into top-level tokens (respects (), {}, []). -function splitTopLevelTokens(str) { - const out = []; - let buf = ''; - let paren = 0, brace = 0, bracket = 0; - for (const ch of (str || '')) { - if (ch === '(') paren++; - else if (ch === ')') paren = Math.max(0, paren - 1); - else if (ch === '{') brace++; - else if (ch === '}') brace = Math.max(0, brace - 1); - else if (ch === '[') bracket++; - else if (ch === ']') bracket = Math.max(0, bracket - 1); - if (ch === ',' && paren === 0 && brace === 0 && bracket === 0) { - const t = buf.trim(); - if (t) out.push(t); - buf = ''; - } else { - buf += ch; - } - } - const t = buf.trim(); - if (t) out.push(t); - return out; -} - -// Extract the hold key from a hold(key, ...) token. -function _extractHoldKey(part) { - const tl = part.toLowerCase(); - if (!tl.startsWith('hold(') || !tl.endsWith(')')) return null; - const inner = part.slice('hold('.length, -1); - const args = splitTopLevelTokens(inner); - return args.length >= 1 ? args[0].trim().toLowerCase() : null; -} - -// Extract keys from inputs text -function extractKeysFromInputs() { - const txt = (getEl('comboInputs')?.value || '').toString(); - if (!txt.trim()) return []; - - const parts = splitTopLevelTokens(txt).map(x => x.toLowerCase()); - const keys = new Set(); - - parts.forEach(part => { - // hold(key, time) or hold(key, time, {body}) — new or existing form - if (/^hold\s*\(/i.test(part)) { - const hk = _extractHoldKey(part); - if (hk) keys.add(hk); - // Also extract keys from {body} if present - const bodyM = part.match(/\{([^}]*)\}\s*\)$/); - if (bodyM) { - splitTopLevelTokens(bodyM[1]).forEach(bi => { - const bit = bi.trim().toLowerCase(); - if (!/^wait[_a-z]*[:(\s]/i.test(bit)) keys.add(bit); - }); - } - return; - } - // key{time} shorthand - let m = part.match(/^([^{]+)\{/); - if (m) { - keys.add(m[1].trim()); - return; - } - // wait(...) -> skip - if (/^wait[_a-z]*[:(\s]/i.test(part)) return; - // [group] -> extract items using top-level split - m = part.match(/^\[(.+)\]$/s); - if (m) { - splitTopLevelTokens(m[1]).forEach(gi => { - const gg = gi.trim().toLowerCase(); - if (/^hold\s*\(/i.test(gg)) { - const hk = _extractHoldKey(gg); - if (hk) keys.add(hk); - return; - } - let mm = gg.match(/^([^{]+)\{/); - if (mm) { keys.add(mm[1].trim()); return; } - mm = gg.match(/^wait\(\s*([^,]+)\s*,/i); - if (mm) { keys.add(mm[1].trim()); return; } - if (!/^wait[_a-z]*[:(\s]/i.test(gg)) keys.add(gg); - }); - return; - } - // Plain key - keys.add(part); - }); - - return Array.from(keys).sort(); -} - -function readKeyImagesFromUI() { - const container = getEl('keyImagesEditor'); - if (!container) return; - const inputs = container.querySelectorAll('input[data-key]'); - const next = {}; - inputs.forEach(inp => { - const k = (inp.getAttribute('data-key') || '').trim().toLowerCase(); - const url = (inp.value || '').toString().trim(); - if (k && url) next[k] = url; - }); - appState.keyImages = next; -} - -function renderKeyImagesEditor() { - // Generic mode only - if (appState.targetGame === 'wuthering_waves') return; - - readKeyImagesFromUI(); - const container = getEl('keyImagesEditor'); - if (!container) return; - container.innerHTML = ''; - - const keys = extractKeysFromInputs(); - if (keys.length === 0) { - container.innerHTML = '
Enter inputs above to see key image fields.
'; - return; - } - - keys.forEach(k => { - const row = document.createElement('div'); - row.className = 'key-image-row'; - - const label = document.createElement('span'); - label.className = 'key-image-label'; - label.textContent = k.toUpperCase(); - - const input = document.createElement('input'); - input.type = 'text'; - input.setAttribute('data-key', k); - input.placeholder = 'https://... or emoji'; - input.value = (appState.keyImages[k] || '').toString(); - - const preview = document.createElement('div'); - preview.className = 'key-image-preview'; - const v = (appState.keyImages[k] || '').toString().trim(); - if (v) { - preview.style.display = 'flex'; - if (/^https?:\/\//i.test(v)) { - preview.innerHTML = ``; - } else { - preview.innerHTML = `${escapeHtml(v)}`; - } - } else { - preview.style.display = 'none'; - } - - input.addEventListener('input', () => { - const url = input.value.trim(); - if (url) { - appState.keyImages[k] = url; - preview.style.display = 'flex'; - if (/^https?:\/\//i.test(url)) { - preview.innerHTML = ``; - } else { - preview.innerHTML = `${escapeHtml(url)}`; - } - } else { - delete appState.keyImages[k]; - preview.style.display = 'none'; - } - }); - - row.appendChild(label); - row.appendChild(input); - row.appendChild(preview); - container.appendChild(row); - }); -} - // Demo video: normalize YouTube link to embed URL function getYouTubeEmbedUrl(url) { const s = (url || '').toString().trim(); @@ -308,9 +137,6 @@ function setEditorFields(data) { appState.keyImages = (typeof data.key_images === 'object' && data.key_images !== null) ? { ...data.key_images } : {}; // Target game & WW data - appState.targetGame = normalizeTargetGame(data.target_game || 'generic'); - const gameSelect = getEl('targetGameSelect'); - if (gameSelect) gameSelect.value = appState.targetGame; // WW character library const charsList = Array.isArray(data.ww_characters) ? data.ww_characters : []; @@ -348,7 +174,7 @@ function setEditorFields(data) { appState.wwCurrentChar = null; } - syncGameUIVisibility(); + renderWwPanels(); } const noFailModeEl = getEl('noFailMode'); @@ -362,8 +188,6 @@ if (noFailModeEl) { const saveBtn = getEl('saveBtn'); if (saveBtn) { saveBtn.addEventListener('click', () => { - readKeyImagesFromUI(); - const name = (getEl('comboName')?.value || '').toString(); const inputs = (getEl('comboInputs')?.value || '').toString(); const enders = (getEl('comboEnders')?.value || '').toString(); @@ -641,14 +465,7 @@ function updateComboInputHighlight() { const inputsEl = getEl('comboInputs'); if (inputsEl) { - let t = null; - inputsEl.addEventListener('input', () => { - updateComboInputHighlight(); - if (t) clearTimeout(t); - t = setTimeout(() => { - renderKeyImagesEditor(); - }, 150); - }); + inputsEl.addEventListener('input', updateComboInputHighlight); inputsEl.addEventListener('scroll', () => { const mirror = getEl('comboInputHighlight'); if (mirror) { @@ -659,11 +476,3 @@ if (inputsEl) { // Initial highlight if textarea already has content (e.g. restored state) updateComboInputHighlight(); } - -const keyImagesDetails = getEl('keyImagesDetails'); -if (keyImagesDetails) { - keyImagesDetails.addEventListener('toggle', () => { - // Ensure UI is up-to-date when opening/closing - renderKeyImagesEditor(); - }); -} diff --git a/static/app/practice.js b/static/app/practice.js index ae01117..593fde1 100644 --- a/static/app/practice.js +++ b/static/app/practice.js @@ -336,7 +336,7 @@ const stepToggleEl = getEl('stepDisplayToggle'); if (stepToggleEl) { stepToggleEl.addEventListener('change', () => { appState.stepDisplayMode = stepToggleEl.checked ? 'images' : 'icons'; - syncGameUIVisibility(); + renderWwPanels(); refreshTimelineIfLoaded(); }); } diff --git a/static/app/teams.js b/static/app/teams.js index 9c566d5..63ad22d 100644 --- a/static/app/teams.js +++ b/static/app/teams.js @@ -1,10 +1,5 @@ // Teams page: team cards, the team editor, the character icon library, and the combo's Team dropdown. -// ComboTracker is Wuthering Waves only; every combo uses the WW team/character features. -function normalizeTargetGame(_v) { - return 'wuthering_waves'; -} - function ensureWwAbilityShape(obj) { const out = { "1": {}, "2": {}, "3": {} }; if (!obj || typeof obj !== 'object') return out; @@ -29,23 +24,11 @@ function ensureWwSlotShape(obj) { return out; } -function syncGameUIVisibility() { - const isWW = appState.targetGame === 'wuthering_waves'; - - const wwPanels = getEl('wwPanels'); - if (wwPanels) wwPanels.classList.toggle('hidden', !isWW); - - const keyDetails = getEl('keyImagesDetails'); - if (keyDetails) keyDetails.classList.toggle('hidden', isWW); - - if (isWW) { - renderWwTeamEditor(); - renderWwCharacterEditor(); - renderWwDashPreview(); - } - - // Re-render key images editor (generic mode only). - renderKeyImagesEditor(); +/** Re-render the team and character panels after the editor payload changes. */ +function renderWwPanels() { + renderWwTeamEditor(); + renderWwCharacterEditor(); + renderWwDashPreview(); } // ----- WW helper: image preview ----- @@ -327,26 +310,6 @@ function renderTeamSlotsEditor() { }); } -// Resolve current team slots → appState.wwSwapImages / wwLmbImages / wwAbilityImages for timeline -function _resolveTeamImagesToState() { - const swap = { "1": "", "2": "", "3": "" }; - const lmb = { "1": "", "2": "", "3": "" }; - const ability = { "1": {}, "2": {}, "3": {} }; - appState.wwTeamSlots.forEach((charKey, idx) => { - const sk = String(idx + 1); - const char = charKey ? appState.wwCharacters[charKey] : null; - if (!char) return; - if (char.swap_image) swap[sk] = char.swap_image; - if (char.lmb_image) lmb[sk] = char.lmb_image; - if (char.ability_images && typeof char.ability_images === 'object') { - ability[sk] = { ...char.ability_images }; - } - }); - appState.wwSwapImages = swap; - appState.wwLmbImages = lmb; - appState.wwAbilityImages = ability; -} - // ----- WW Character editor ----- function _buildCharRow(labelText, inputValue, onInput) { const row = document.createElement('div'); diff --git a/static/app/timeline.js b/static/app/timeline.js index a90c2ec..2c88a47 100644 --- a/static/app/timeline.js +++ b/static/app/timeline.js @@ -398,7 +398,6 @@ function commitStepFieldEdit(runtimeIdx, s, field, newValue) { if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); // Save via the same path as the Save/Update button. - readKeyImagesFromUI(); const toggle = getEl('stepDisplayToggle'); sendMessage('save_combo', { name: (getEl('comboName')?.value || '').toString(), diff --git a/static/style.css b/static/style.css index 673d63a..9dfad4b 100644 --- a/static/style.css +++ b/static/style.css @@ -74,13 +74,6 @@ h2 { } /* Form Elements */ -.form-grid { - display: grid; - grid-template-columns: 120px 1fr; - gap: 12px; - align-items: start; -} - label { text-align: right; padding-top: 5px; @@ -167,22 +160,7 @@ textarea { } /* Demo video */ -/* Demo video collapsible spans full form grid width */ -.form-grid .demo-video-details { - grid-column: 1 / -1; -} -.demo-video-row { - grid-column: 2; -} -.demo-video-input { - width: 100%; - padding: 8px 12px; - background: var(--bg-primary); - border: 1px solid var(--accent-grey); - border-radius: var(--border-radius); - color: var(--text-primary); - font-size: 14px; -} +/* Demo video */ .demo-video-embed-wrap { grid-column: 1 / -1; margin-top: 8px; @@ -286,24 +264,6 @@ textarea { opacity: 1; } -.help-inline { - color: var(--text-secondary); - font-size: 11px; -} - -.checkbox-label { - display: inline-flex; - align-items: center; - gap: 6px; - cursor: pointer; -} - -.button-row { - margin-top: 15px; - display: flex; - gap: 10px; -} - button { background: var(--accent-blue); color: var(--bg-primary); @@ -364,31 +324,7 @@ button.danger:hover { user-select: none; } -/* Key image mapping editor */ -.key-images-editor { - display: grid; - grid-template-columns: 1fr; - gap: 8px; - padding: 8px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - background: rgba(0, 0, 0, 0.10); -} - -/* Two-column row controls */ -.two-col-row, .ww-team-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - align-items: center; -} - -.two-col-buttons { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - +/* Combo Steps header */ .combo-steps-header { display: flex; align-items: center; @@ -471,164 +407,10 @@ button.danger:hover { vertical-align: middle; } -.collapsible { - margin-bottom: 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--border-radius); - background: rgba(0, 0, 0, 0.10); -} - -.collapsible > summary { - cursor: pointer; - padding: 10px 12px; - list-style: none; - font-weight: 800; - color: var(--text-secondary); -} - -.collapsible > summary::-webkit-details-marker { - display: none; -} - -.collapsible > summary:after { - content: "▾"; - float: right; - opacity: 0.8; - transition: transform 140ms ease; -} - -.collapsible[open] > summary:after { - transform: rotate(180deg); -} - -.collapsible-body { - padding: 0 12px 12px 12px; -} - .hidden { display: none; } -.transcribe-row { - display: flex; - flex-direction: column; - gap: 8px; -} - -.transcribe-valid-keys-wrap label { - display: block; - margin-bottom: 4px; - text-align: left; -} - -.transcribe-valid-keys-wrap .transcribe-valid-input { - width: 100%; - max-width: 400px; -} - -.transcribe-valid-keys-wrap .transcribe-start-key-input { - max-width: 160px; -} - -/* Collapsible Sections */ -.section-details > summary { - cursor: pointer; - list-style: none; - position: relative; - outline: none; -} - -.section-details > summary::-webkit-details-marker { - display: none; -} - -.section-details > summary::after { - content: "▼"; - position: absolute; - right: 0; - top: 0; - bottom: 0; - height: fit-content; - margin: auto; - font-size: 1.2rem; - color: var(--text-secondary); - transition: transform 0.2s; - opacity: 0.5; - pointer-events: none; -} - -.section-details[open] > summary::after { - transform: rotate(180deg); -} - -.section-details > summary h2, -.section-details > summary h3 { - margin-top: 0; - padding-right: 30px; -} - -.section-details:not([open]) > summary h2, -.section-details:not([open]) > summary h3 { - margin-bottom: 0; - border-bottom: none; -} - -.ww-team-buttons { - margin-top: 10px; - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -/* Full-width row inside form-grid */ -.full-row { - grid-column: 1 / -1; -} - -/* WW panels container */ -#wwPanels { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 6px; -} - -.ww-panel { - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--border-radius); - background: rgba(0, 0, 0, 0.10); - padding: 12px 14px; -} - -.ww-panel-header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin-bottom: 10px; -} - -.ww-panel-title { - font-family: 'Fira Code', monospace; - font-weight: 800; - font-size: 13px; - color: var(--text-primary); - margin: 0; - white-space: nowrap; -} - -.ww-panel-select { - flex: 1; - min-width: 140px; - max-width: 240px; -} - -.ww-panel-name-input { - flex: 1; - min-width: 120px; - max-width: 200px; -} - /* Portrait-aware character picker (native select menus cannot display images). */ .ww-char-picker { position: relative; @@ -757,15 +539,6 @@ button.danger:hover { user-select: none; } -.ww-slot-label { - font-family: 'Fira Code', monospace; - font-size: 12px; - font-weight: 700; - color: var(--text-secondary); - white-space: nowrap; - min-width: 48px; -} - .ww-slot-portrait { width: 44px; height: 44px; @@ -849,58 +622,7 @@ button.danger:hover { margin-top: 4px; } -/* Wuthering Waves ability image editor (legacy kept for compatibility) */ -.ww-ability-editor { - display: grid; - grid-template-columns: 1fr; - gap: 10px; -} - -.ww-ability-char { - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--border-radius); - background: rgba(0, 0, 0, 0.10); - padding: 10px; - transition: opacity 0.15s, border-color 0.15s, box-shadow 0.15s; -} - -.ww-ability-char[draggable="true"] .ww-ability-key { - cursor: grab; -} - -.ww-ability-char[draggable="true"] .ww-ability-key::before { - content: '⠿ '; - opacity: 0.4; - font-size: 11px; -} - -.ww-ability-char.ww-drag-active { - opacity: 0.4; -} - -.ww-ability-char.ww-drag-over { - border-color: var(--accent-color, #7c6af7); - box-shadow: 0 0 0 2px rgba(124, 106, 247, 0.35); -} - -.ww-ability-char-title { - font-weight: 900; - color: var(--text-primary); - margin-bottom: 10px; - font-size: 13px; -} - -.ww-ability-key { - font-family: 'Fira Code', monospace; - font-weight: 900; - text-align: left; - padding: 6px 10px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - width: fit-content; -} - +/* Character icon rows (Teams page) */ .ww-ability-row { display: grid; grid-template-columns: 64px 1fr 40px; @@ -933,31 +655,7 @@ button.danger:hover { display: none; } -.key-image-row { - display: grid; - grid-template-columns: 56px 1fr 40px; - gap: 8px; - align-items: center; -} - -.key-image-key { - font-family: 'Fira Code', monospace; - font-weight: 800; - color: var(--text-primary); - text-align: center; - padding: 8px 6px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); -} - -/* Status Section */ -.status-section { - display: grid; - grid-template-columns: 1fr; - gap: 8px; -} - +/* Status line above the timeline */ #statusDisplay { font-size: 1.2rem; font-weight: bold; @@ -1054,8 +752,7 @@ button.danger:hover { color: var(--text-primary); } -.time-display, -.stats-display { +.time-display { color: var(--text-secondary); font-size: 0.9rem; text-align: center; @@ -1530,13 +1227,6 @@ button.danger:hover { font-size: 3em; } -.step-primary-small { - font-size: 13px; - font-weight: 700; - letter-spacing: 0.2px; - text-transform: uppercase; -} - .step-secondary { margin-top: 1px; font-size: 11px; @@ -1557,22 +1247,6 @@ button.danger:hover { pointer-events: none; } -.hold-progress { - margin-top: 8px; - height: 10px; - background: rgba(255, 255, 255, 0.14); - border: 1px solid rgba(255, 255, 255, 0.22); - border-radius: var(--border-radius); - overflow: hidden; -} - -.hold-progress-fill { - height: 100%; - width: 0%; - background: var(--accent-green); - transition: width 60ms linear; -} - .step.completed { border-color: var(--accent-green); background: rgba(74, 255, 136, 0.1); @@ -1664,35 +1338,6 @@ button.danger:hover { justify-content: flex-start; } -.mini-sequence-step { - font-size: 11px; - padding: 2px 4px; - border-radius: 2px; - background: rgba(255, 255, 255, 0.05); -} - -.mini-sequence-step-active { - background: rgba(74, 158, 255, 0.3); - font-weight: bold; -} - -.mini-sequence-step-completed { - background: rgba(74, 255, 136, 0.3); -} - -.mini-sequence-arrow { - font-size: 10px; - color: rgba(138, 43, 226, 0.7); - margin: 0 2px; -} - -.mini-sequence-progress { - font-size: 9px; - color: var(--text-secondary); - text-align: center; - margin-top: 2px; -} - /* Sequential subgroups */ .step-sequence { position: relative; @@ -1727,31 +1372,6 @@ button.danger:hover { } /* Timeline Step Containers */ -.timeline-step { - position: relative; -} - -.timeline-step-group, -.timeline-step-sequence { - position: relative; -} - -.timeline-step-sequence { - border: 2px solid rgba(138, 43, 226, 0.5); - border-radius: var(--border-radius); - padding: 8px; - background: rgba(138, 43, 226, 0.05); -} - -.sequence-header { - font-size: 10px; - text-transform: uppercase; - color: rgba(138, 43, 226, 0.9); - font-weight: bold; - margin-bottom: 4px; - text-align: center; -} - .sequence-item { min-width: 70px; padding: 6px 10px; @@ -1763,24 +1383,6 @@ button.danger:hover { transition: var(--transition); } -.sequence-item.sequence-item-active { - border-color: var(--accent-blue); - box-shadow: 0 0 8px rgba(74, 158, 255, 0.5); - animation: pulse 1s infinite; -} - -.sequence-item.sequence-item-completed { - border-color: var(--accent-green); - background: rgba(74, 255, 136, 0.1); -} - -.sequence-progress { - font-size: 11px; - color: var(--text-secondary); - text-align: center; - margin-top: 4px; -} - /* Icons and images */ .mouse-icon { display: inline-flex; @@ -1817,10 +1419,6 @@ button.danger:hover { display: inline-block; } -.hold-label { - margin-top: 4px; -} - /* Wait and hold states */ .step.wait { border-color: var(--accent-yellow); @@ -1886,8 +1484,7 @@ button.danger:hover { } /* Tables */ -.results-table, -.fail-table { +.results-table { max-height: 300px; overflow-y: auto; border: 1px solid #333; @@ -1904,13 +1501,11 @@ button.danger:hover { top: 0; } -#resultsBody, -#failBody { +#resultsBody { display: contents; } -.result-row, -.fail-row { +.result-row { display: grid; grid-template-columns: 1fr 130px 135px 158px; padding: 6px 10px; @@ -1919,8 +1514,7 @@ button.danger:hover { transition: var(--transition); } -.result-row:hover, -.fail-row:hover { +.result-row:hover { background: var(--bg-hover); } @@ -2021,8 +1615,6 @@ button.danger:hover { border-radius: 0; } -.timeline-section .collapsible, -.timeline-section .collapsible-body, .timeline-section .timeline-viewport, .timeline-section .timeline, .timeline-section .combo-steps-header, @@ -2030,10 +1622,6 @@ button.danger:hover { background: transparent; } -.timeline-section .collapsible { - border-color: rgba(255, 255, 255, 0.1); -} - .timeline-section .step { border-color: #808080; } @@ -2151,16 +1739,6 @@ body.timeline-window-view #openTimelineWindowBtn { color: var(--text-primary); } -.ww-char-sync .ww-moves-link { - color: var(--accent-blue); - text-decoration: none; - white-space: nowrap; -} - -.ww-char-sync .ww-moves-link:hover { - text-decoration: underline; -} - /* Color legend (info button above Combo Steps). Swatches reuse the timeline's colors. */ .legend-panel { display: grid; diff --git a/stats_recording.py b/stats_recording.py index df3c916..7a928fe 100644 --- a/stats_recording.py +++ b/stats_recording.py @@ -24,16 +24,6 @@ def ensure_combo_stats(engine: Any, name: str) -> None: engine.combo_stats[name].setdefault(k, v) -def combo_avg_ms(engine: Any, name: str) -> float | None: - """Average success completion time in ms for the given combo, or None.""" - ensure_combo_stats(engine, name) - s = int(engine.combo_stats[name].get("success", 0) or 0) - total = int(engine.combo_stats[name].get("total_success_ms", 0) or 0) - if s <= 0 or total <= 0: - return None - return total / float(s) - - def format_percent(success: int, fail: int) -> str: """Format success/(success+fail) as a percentage string.""" total = success + fail @@ -108,7 +98,7 @@ def record_combo_success(engine: Any, completion_ms: float | int | None = None) engine.combo_stats[name]["success"] += 1 if completion_ms is None and getattr(engine, "start_time", 0): - completion_ms = (engine._now() - engine.start_time) * 1000.0 + completion_ms = (engine._now() - engine.start_time) * 1000.0 try: ms = int(round(float(completion_ms))) if completion_ms is not None else None except Exception: @@ -157,7 +147,7 @@ def record_combo_fail( idx_i = 0 exp = expected_label if not exp: - step = engine._active_step() if hasattr(engine, "_active_step") else None + step = engine._active_runtime_step() if hasattr(engine, "_active_runtime_step") else None exp = step_introspection.expected_label_for_step(step) if step else "—" record_fail_detail( diff --git a/tests/test_combo_overview.py b/tests/test_combo_overview.py index 57c0ff9..7d056fc 100644 --- a/tests/test_combo_overview.py +++ b/tests/test_combo_overview.py @@ -52,7 +52,7 @@ def test_team_assignment_is_reported(self): def test_init_and_stat_update_carry_the_overview(self): engine = _engine_with_two_combos() self.assertEqual(len(engine.init_payload()["overview"]), 2) - msg = engine.stat_update_payload() + msg = ui.stat_update_payload(engine) self.assertEqual(msg["type"], "stat_update") self.assertIn("stats", msg) self.assertEqual(len(msg["overview"]), 2) diff --git a/ui_server.py b/ui_server.py index 448f2fc..df2efd4 100644 --- a/ui_server.py +++ b/ui_server.py @@ -16,6 +16,7 @@ from pynput import keyboard, mouse from combo_engine import ComboTrackerEngine +import input_normalization from macro_player import MacroPlayer from profiling.transcription_log import TranscriptionLogWriter from transcriber import Transcriber @@ -428,7 +429,7 @@ async def _main(): def start_input_listeners() -> tuple[keyboard.Listener, mouse.Listener]: def on_key_press(key: keyboard.Key | keyboard.KeyCode | None) -> None: event_time = time.perf_counter() - input_name = engine.normalize_key(key) + input_name = input_normalization.normalize_key(key) _emit_overlay_input(input_name, True) # macro playback shows on the overlay too # Macro output is matched by origin before hotkey handling. This prevents @@ -482,7 +483,7 @@ def on_key_press(key: keyboard.Key | keyboard.KeyCode | None) -> None: def on_key_release(key: keyboard.Key | keyboard.KeyCode | None) -> None: event_time = time.perf_counter() - input_name = engine.normalize_key(key) + input_name = input_normalization.normalize_key(key) _emit_overlay_input(input_name, False) if macro_player.consume_synthetic_event(input_name, False): return @@ -499,7 +500,7 @@ def on_key_release(key: keyboard.Key | keyboard.KeyCode | None) -> None: def on_mouse_click(_x: float, _y: float, button: mouse.Button, pressed: bool) -> None: event_time = time.perf_counter() - btn = engine.normalize_mouse(button) + btn = input_normalization.normalize_mouse(button) _emit_overlay_input(btn, pressed) if macro_player.consume_synthetic_event(btn, pressed): return From 11bfad4ec715e0bf094b329c8d74f90f66534567 Mon Sep 17 00:00:00 2001 From: WXP Date: Wed, 30 Sep 2026 22:57:52 -0400 Subject: [PATCH 4/7] update --- combo_engine.py | 4 +- combo_engine_ui_adapter.py | 6 +- static/app/combos.js | 77 +++++++++++- static/app/shell.js | 11 +- static/index.html | 237 +++++++++++++++++------------------ static/shell.css | 39 +++++- tests/test_cancel_attempt.py | 50 ++++++++ 7 files changed, 292 insertions(+), 132 deletions(-) create mode 100644 tests/test_cancel_attempt.py diff --git a/combo_engine.py b/combo_engine.py index 97369e1..4627852 100644 --- a/combo_engine.py +++ b/combo_engine.py @@ -805,7 +805,7 @@ def _reset_to_start(self) -> None: self._reset_hold_state() self._reset_wait_state() self._reset_group_state() - self.ui_adapter.reset() + self.ui_adapter.reset(emit=self._send) def _on_combo_completed(self, total_ms: float) -> None: """Record success, reset to start, and notify UI. Single place for combo completion.""" @@ -849,7 +849,7 @@ def _reset_after_fail(self) -> None: self._ender_cooldown_until.clear() self.ww.ww_active_character = None self._hold_max_held_ms = 0.0 - self.ui_adapter.reset() + self.ui_adapter.reset(emit=self._send) for s in self.runtime_steps: s.reset() self._reset_hold_state() diff --git a/combo_engine_ui_adapter.py b/combo_engine_ui_adapter.py index afdd7c7..c0f2a8b 100644 --- a/combo_engine_ui_adapter.py +++ b/combo_engine_ui_adapter.py @@ -28,7 +28,11 @@ class UIAdapter: wait_anim_sig: tuple | None = None active_step_sig: Any | None = None - def reset(self) -> None: + def reset(self, emit: EmitFunc | None = None) -> None: + # A nested wait/spam animation still running in the UI must be told to stop, or the + # frontend keeps animating and re-latches onto the first tile of the reset timeline. + if emit is not None and self.wait_anim_sig is not None: + emit({"type": "spam_end" if self.wait_anim_sig[0] == "spam" else "wait_end"}) self.wait_anim_sig = None self.active_step_sig = None diff --git a/static/app/combos.js b/static/app/combos.js index 8135295..18cccf7 100644 --- a/static/app/combos.js +++ b/static/app/combos.js @@ -1,4 +1,4 @@ -// Combos page: the combo editor, the inputs highlighter, and record (transcribe) / replay (macro) modes. +// Combo editor (Practice page): the editor fields, the inputs highlighter, and record (transcribe) / replay (macro) modes. // UI Initialization function initializeUI(data) { @@ -117,6 +117,7 @@ function setEditorFields(data) { const inputsEl = getEl('comboInputs'); if (inputsEl) { inputsEl.value = data.inputs || ''; + appState.savedInputs = inputsEl.value; if (typeof updateComboInputHighlight === 'function') updateComboInputHighlight(); } getEl('comboEnders').value = data.enders || ''; @@ -237,9 +238,6 @@ if (comboDemoVideoEl) { comboDemoVideoEl.addEventListener('change', () => updateDemoVideoEmbed(comboDemoVideoEl.value)); } -// Practice it: jump to the Practice page with this combo -getEl('practiceBtn')?.addEventListener('click', () => showPage('practice')); - // New combo button const newBtn = getEl('newBtn'); if (newBtn) { @@ -476,3 +474,74 @@ if (inputsEl) { // Initial highlight if textarea already has content (e.g. restored state) updateComboInputHighlight(); } + +// --------------------------------------------------------------------------- +// Editing focus: the Combo Steps tile for the input under the text cursor gets outlined, +// so you can see which step you're editing. The timeline shows the saved combo, so this +// only follows tokens that still line up with it (edits after the cursor are fine). +// --------------------------------------------------------------------------- + +/** Top-level tokens with their [start, end) offsets in the text (same split as splitInputsTokens). */ +function splitInputsTokenSpans(str) { + const spans = []; + let depth = 0; + let start = 0; + const push = (end) => { + const raw = str.slice(start, end); + const lead = raw.length - raw.trimStart().length; + const text = raw.trim(); + if (text) spans.push({ text, start: start + lead, end: start + lead + text.length }); + }; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + if (ch === '(' || ch === '{' || ch === '[') depth++; + else if (ch === ')' || ch === '}' || ch === ']') depth = Math.max(0, depth - 1); + else if (ch === ',' && depth === 0) { push(i); start = i + 1; } + } + push(str.length); + return spans; +} + +/** Runtime step indices for the token under the cursor, or [] when it can't be matched to the timeline. */ +function runtimeIndicesAtCursor() { + const ta = getEl('comboInputs'); + if (!ta || document.activeElement !== ta) return []; + const text = ta.value || ''; + const spans = splitInputsTokenSpans(text); + if (spans.length === 0) return []; + const caret = ta.selectionStart || 0; + // The token whose text (or trailing comma/space) holds the caret. + let tokIdx = spans.findIndex((sp, i) => caret <= sp.end || i === spans.length - 1 || caret < spans[i + 1].start); + if (tokIdx < 0) tokIdx = spans.length - 1; + + // Only trust the mapping while every token up to the cursor matches the saved combo. + const saved = splitInputsTokens(appState.savedInputs || ''); + for (let i = 0; i <= tokIdx; i++) { + if ((saved[i] || '').toLowerCase() !== spans[i].text.toLowerCase()) return []; + } + const srcMap = buildRuntimeToSourceMap(saved); + const out = []; + srcMap.forEach((src, runtimeIdx) => { if (src.includes(tokIdx)) out.push(runtimeIdx); }); + return out; +} + +function applyEditFocus() { + const timeline = getEl('comboTimeline'); + if (!timeline) return; + const wanted = new Set(runtimeIndicesAtCursor()); + timeline.querySelectorAll('.step-edit-focus').forEach(el => el.classList.remove('step-edit-focus')); + if (wanted.size === 0) return; + [...timeline.children].forEach(tile => { + const raw = (tile.dataset.stepIndices || '').trim(); + if (!raw) return; + if (raw.split(',').some(v => wanted.has(Number.parseInt(v, 10)))) tile.classList.add('step-edit-focus'); + }); +} + +if (inputsEl) { + ['click', 'keyup', 'focus', 'input', 'select'].forEach(ev => inputsEl.addEventListener(ev, applyEditFocus)); + inputsEl.addEventListener('blur', applyEditFocus); + // Re-apply after every timeline re-render (live attempts, toggles, saves). + const timeline = getEl('comboTimeline'); + if (timeline) new MutationObserver(() => { if (document.activeElement === inputsEl) applyEditFocus(); }).observe(timeline, { childList: true }); +} diff --git a/static/app/shell.js b/static/app/shell.js index a13f779..6820e95 100644 --- a/static/app/shell.js +++ b/static/app/shell.js @@ -2,8 +2,7 @@ // the Combos list, History, Settings, toasts, and the backend message dispatch. Loads last. const PAGES = { - practice: { title: 'Practice', overline: 'Now practicing' }, - combos: { title: 'Combos', overline: 'Edit and record' }, + practice: { title: 'Practice', overline: 'Practice, edit and record' }, teams: { title: 'Teams', overline: 'Your teams' }, characters: { title: 'Characters', overline: 'Moves and rotations' }, history: { title: 'History', overline: 'Every combo' }, @@ -17,6 +16,7 @@ const isTimelineView = document.body.classList.contains('timeline-window-view'); // --------------------------------------------------------------------------- function showPage(name, opts) { + // Combos used to be its own page; old #combos links land on Practice. const page = PAGES[name] ? name : 'practice'; document.querySelectorAll('.page').forEach(el => { el.hidden = el.dataset.page !== page; }); document.querySelectorAll('.navbtn[data-page]').forEach(btn => { @@ -58,6 +58,13 @@ getEl('railToggle')?.addEventListener('click', () => { writeStoredFlag('ctRailCompact', compact); }); +// The Edit combo panel under the timeline remembers whether it was open +const comboEditDetails = getEl('comboEditDetails'); +if (comboEditDetails) { + comboEditDetails.open = readStoredFlag('ctComboEditOpen', true); + comboEditDetails.addEventListener('toggle', () => writeStoredFlag('ctComboEditOpen', comboEditDetails.open)); +} + // --------------------------------------------------------------------------- // Toast (replaces the old status text for saves) // --------------------------------------------------------------------------- diff --git a/static/index.html b/static/index.html index 0353baf..88715fa 100644 --- a/static/index.html +++ b/static/index.html @@ -28,10 +28,6 @@ Practice - - - - - -
Status: Select a combo to start -
-
-
- -
-
- - - -
-
-

Attempt log

-
- - -
-
-
-
- Input - Step Time (ms) - Total Time(ms) - Avg Step Time (ms) -
-
-
-

Turn on Log attempts to list each step's time here.

-
- - - - + + +
+
+

Attempt log

+
+ + +
+
+
+
+ Input + Step Time (ms) + Total Time(ms) + Avg Step Time (ms) +
+
+
+

Turn on Log attempts to list each step's time here.

+
@@ -445,7 +439,6 @@

Data

+ +
+
+ + + +
+
+
+
- -
- - +
@@ -358,17 +355,6 @@

Character icons

- - -
- -
diff --git a/static/shell.css b/static/shell.css index 6a282ba..6e1f0b5 100644 --- a/static/shell.css +++ b/static/shell.css @@ -758,21 +758,6 @@ select.input { padding-right: 8px; } .app .combo-input-wrap #comboInputs:focus { border-color: var(--ct-accent); box-shadow: 0 0 0 2px rgba(46, 197, 234, .2); } -.reads-as { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } -.reads-as-label { font-size: 12px; color: var(--ct-muted); margin-right: 4px; } -.reads-as-items { display: contents; } -.reads-as-item { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px 4px 4px; - border-radius: 8px; - background: var(--ct-surface); - font-size: 12.5px; -} - -.reads-as-more { min-height: 30px; padding: 0 10px; font-size: 12.5px; } - .app .demo-video-embed-wrap { margin: 0; } .app .demo-video-embed-wrap iframe { max-width: 100%; border-radius: 10px; }