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..fb5aad0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,13 @@ 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 + +# pytest +.pytest_cache/ + +# Raw encore.moe snapshot (Characters page "Download raw data"); source for tools/ww_build_moves.py +/data/encore_raw/ 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..b51eb64 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 from **Settings > Stream overlays**, 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 + +The **Characters** page (in the sidebar, or `http://localhost:8737/characters.html`) shows what each Wuthering Waves character's buttons do. It covers inputs only: no damage numbers. + +- **Moves**: the Basic Attack chain (A1, A2, ...), what holding LMB does, which hit LMB lands on after the Intro, Skill, Liberation, a Heavy, a Dodge Counter or a Mid-air Attack, and each move with its key. The timeline's move names use the same data. +- **Team rotations**: the community's team setups for that character with author and video, from [AntoCrasher's rotation 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. + +The move data ships with the app in `static/data/ww_characters.json`. It was drafted from the [encore.moe](https://encore.moe) API and checked by hand for the characters marked "Checked by hand"; edit that file to fix or add details. **Download raw data** saves encore.moe's unedited files to `data/encore_raw/` without changing anything shown; `python tools/ww_build_moves.py` then drafts any new characters from them (hand-checked entries are left alone). Rotations are downloaded when you open them and cached in `ww_library_cache/`. + --- ## Documentation diff --git a/_combo_commands.py b/_combo_commands.py index e7229f1..98e47e6 100644 --- a/_combo_commands.py +++ b/_combo_commands.py @@ -7,9 +7,26 @@ 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 +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: @@ -20,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 @@ -66,28 +83,30 @@ 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() 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" @@ -101,28 +120,21 @@ 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." - 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,11 +178,9 @@ 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) + stats_recording.ensure_combo_stats(engine, name) engine.set_active_combo(name, emit=False) engine.save_combos() @@ -184,19 +194,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 @@ -223,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({"type": "stat_update", "stats": engine.stats_text()}) - engine._send({"type": "fail_update", "fail_by_step": engine.failures_by_step()}) + engine._send({"type": "stat_update", "stats": ui.stats_text(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}) @@ -237,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({"type": "stat_update", "stats": engine.stats_text()}) - engine._send({"type": "fail_update", "fail_by_step": engine.failures_by_step()}) + engine._send({"type": "stat_update", "stats": ui.stats_text(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 3747b68..4c1af67 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, @@ -23,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 @@ -98,7 +99,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 +107,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 +114,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 +167,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 +180,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 @@ -234,82 +225,13 @@ 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({"type": "fail_update", "fail_by_step": self.failures_by_step()}) + self._send({"type": "stat_update", "stats": ui.stats_text(self)}) + 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 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) - - 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 _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 +250,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.""" @@ -380,72 +301,12 @@ def save_combos(self) -> bool: # Stats helpers # ------------------------- - 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) - - def stats_text(self): - return ui.stats_text(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 _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) - - 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 # ------------------------- - 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 +361,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 +381,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 +445,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: @@ -712,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] @@ -750,14 +607,10 @@ 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 - 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() @@ -765,27 +618,32 @@ 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}) - 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 @@ -800,7 +658,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 @@ -810,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): @@ -910,7 +763,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): @@ -953,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.""" @@ -997,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() @@ -1152,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, ) @@ -1178,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") @@ -1205,7 +1057,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: @@ -1225,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() @@ -1248,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, ) @@ -1298,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)" @@ -1311,7 +1162,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() @@ -1338,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, ) @@ -1348,24 +1198,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) @@ -1665,11 +1497,10 @@ 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 - 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 @@ -1717,11 +1548,10 @@ 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 - self.last_success_input = holder self._start_ender_cooldown(holder, now) self.ww.on_accepted_key(self, holder) step.complete() @@ -1896,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) @@ -1922,7 +1752,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), @@ -1981,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 @@ -1993,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) @@ -2011,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 50d549e..9487195 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,15 +32,27 @@ 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)}" ) +def combo_overview(engine) -> list[dict[str, Any]]: + """One row per saved combo (its team and step count) for the combo list grouped by team.""" + return [ + { + "name": name, + "team_id": engine.ww.combo_ww_team.get(name, ""), + "steps": len(engine.combos.get(name) or []), + } + for name in sorted(engine.combos.keys()) + ] + + def failures_by_step(engine) -> dict[str, int]: return combo_analytics.failures_by_step(engine) @@ -51,7 +65,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: @@ -91,7 +105,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 "" @@ -114,7 +128,7 @@ def get_editor_payload(engine, target_game_override: str | None = None) -> dict[ 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) @@ -138,7 +152,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, @@ -190,7 +204,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") @@ -214,10 +228,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: @@ -226,7 +240,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") @@ -678,6 +692,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/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/combos.json b/combos.example.json similarity index 100% rename from combos.json rename to combos.example.json 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/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/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-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 28dc276..773e40b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pynput==1.8.1 -websockets==12.0 +websockets==15.0.1 +pyinstaller>=6.0 \ No newline at end of file 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 deleted file mode 100644 index d681145..0000000 --- a/static/app.js +++ /dev/null @@ -1,3062 +0,0 @@ -// WebSocket connection -const ws = new WebSocket('ws://localhost:8765'); - -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 = 'ComboTracker – Timeline'; - document.body.classList.add('timeline-window-view'); -} - -function getTimelineUrl() { - const path = window.location.pathname || '/'; - 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'); -}; - -function sendMessage(type, payload = {}) { - if (ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify({ type, ...payload })); -} - -// Single app state (replaces scattered globals) -const appState = { - stepDisplayMode: 'images', - keyImages: {}, - lastTimelineSteps: null, - lastFailByStep: {}, - showFailCount: false, - collapseChainedPresses: true, - autoScrollEnabled: false, - stepEditMode: true, - editStepsUndoStack: [], - targetGame: 'generic', - 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 || ''); - } -} - -function normalizeTargetGame(v) { - const g = (v || '').toString().trim().toLowerCase(); - return (g === 'wuthering_waves') ? 'wuthering_waves' : 'generic'; -} - -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 -function updateStats(text) { - const el = getEl('statsDisplay'); - if (el) el.textContent = text || 'Stats: —'; -} - -function updateMinTime(text) { - const el = getEl('minTimeDisplay'); - if (el) el.textContent = text || 'Fastest possible: —'; -} - -function updateDifficulty(text) { - const el = getEl('difficultyDisplay'); - if (el) el.textContent = text || 'Difficulty: —'; -} - -function updateUserDifficulty(text) { - const el = getEl('userDifficultyDisplay'); - if (el) el.textContent = text || 'Your difficulty: —'; -} - -function updateAPM(text) { - const el = getEl('apmDisplay'); - if (el) el.textContent = text || 'Practical APM: —'; -} - -function updateAPMMax(text) { - const el = getEl('apmMaxDisplay'); - if (el) el.textContent = text || 'Theoretical max APM: —'; -} - -function setDifficultyColor(el, value) { - if (!el) return; - el.classList.remove('diff-easy', 'diff-med', 'diff-hard', 'diff-insane'); - 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, - }; - - 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); - - 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); - 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); - 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 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 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(); - }); -} - -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: 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(); - }); -} - -ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - appState.batchQueue.push(msg); - if (!appState.isProcessingBatch) processBatch(); -}; diff --git a/static/app/combos.js b/static/app/combos.js new file mode 100644 index 0000000..18cccf7 --- /dev/null +++ b/static/app/combos.js @@ -0,0 +1,547 @@ +// Combo editor (Practice page): the editor fields, 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 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; + renderWwPanels(); + 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 || ''); + } +} + +// 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 || ''; + appState.savedInputs = inputsEl.value; + 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 + + // 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; + } + + renderWwPanels(); +} + +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', () => { + 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)); +} + +// 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) { + inputsEl.addEventListener('input', updateComboInputHighlight); + 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(); +} + +// --------------------------------------------------------------------------- +// 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/core.js b/static/app/core.js new file mode 100644 index 0000000..7be6c78 --- /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: [], + // Combo list: one row per combo (team, steps) 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..593fde1 --- /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'; + renderWwPanels(); + 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..1a2bf29 --- /dev/null +++ b/static/app/shell.js @@ -0,0 +1,446 @@ +// App shell: page navigation, the left rail and its status card, +// the combo list, Settings, toasts, and the backend message dispatch. Loads last. + +const PAGES = { + practice: { title: 'Practice', overline: 'Practice, edit and record' }, + teams: { title: 'Teams', overline: 'Your teams' }, + characters: { title: 'Characters', overline: 'Moves and rotations' }, + settings: { title: 'Settings', overline: 'App' }, +}; +const PAGE_STORAGE_KEY = 'ctPage'; +const isTimelineView = document.body.classList.contains('timeline-window-view'); + +// --------------------------------------------------------------------------- +// Pages +// --------------------------------------------------------------------------- + +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 => { + 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; + closePickers(); + + 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); +}); + +// The combo list lives in the rail; without a rail (phones) it moves to the top of the Practice page. +const comboListCard = getEl('comboListCard'); +const railSpacer = document.querySelector('.rail-spacer'); +const phoneLayout = window.matchMedia('(max-width: 900px)'); +function placeComboList() { + if (!comboListCard) return; + if (phoneLayout.matches) getEl('practiceEditor')?.prepend(comboListCard); + else railSpacer?.before(comboListCard); +} +phoneLayout.addEventListener('change', placeComboList); +placeComboList(); + +// Picking a combo from the rail on another page goes to Practice. +comboListCard?.addEventListener('click', (e) => { + if (e.target.closest('.combo-item') && !getEl('practiceEditor')?.closest('.page:not([hidden])')) showPage('practice'); +}); + +// 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) +// --------------------------------------------------------------------------- + +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 (grouped by team, in the rail) +// --------------------------------------------------------------------------- + +function setComboList(names, active, overview) { + appState.comboNames = Array.isArray(names) ? names.slice() : []; + appState.activeCombo = active || ''; + if (Array.isArray(overview)) 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 selectCombo(name) { + if (!name) return; + if (name !== appState.activeCombo) sendMessage('select_combo', { name }); +} + +function renderComboViews() { + renderComboList(); + renderTeamPicker(); +} + +// Dropdown pickers (the Teams page's team picker): close on outside click or Esc. +function closePickers() { + document.querySelectorAll('.picker-menu:not([hidden])').forEach(menu => { + menu.hidden = true; + menu.closest('.picker')?.querySelector('.picker-btn')?.setAttribute('aria-expanded', 'false'); + }); +} +document.addEventListener('click', (e) => { + if (!e.target.closest('.picker')) closePickers(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closePickers(); +}); + +// Combos grouped by team: each team row (portraits, name, count) opens to its combos. +// Teams with no combos are left out. The active combo's team opens when it becomes active; searching opens every match. +const NO_TEAM = ''; +const openTeams = new Set(); +let openedForCombo = null; // the active combo whose team was last opened automatically + +function comboGroups(names) { + const groups = new Map(); // team id -> combo names, in wwTeams order, "No team" last + appState.wwTeams.forEach(t => groups.set(t.id, [])); + names.forEach(name => { + const id = overviewRow(name)?.team_id || NO_TEAM; + if (!groups.has(id)) groups.set(id, []); + groups.get(id).push(name); + }); + const none = groups.get(NO_TEAM); + if (none) { groups.delete(NO_TEAM); groups.set(NO_TEAM, none); } + return [...groups].filter(([, combos]) => combos.length > 0); +} + +function comboItem(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 label = document.createElement('span'); + label.className = 'combo-item-text'; + label.textContent = name; + const steps = document.createElement('span'); + steps.className = 'combo-item-steps'; + steps.textContent = row && row.steps ? `${row.steps} steps` : ''; + item.append(label, steps); + item.title = steps.textContent ? `${name} (${steps.textContent})` : name; + item.addEventListener('click', () => selectCombo(name)); + return item; +} + +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; + } + const activeTeam = overviewRow(appState.activeCombo)?.team_id ?? null; + if (activeTeam !== null && openedForCombo !== appState.activeCombo) { + openTeams.add(activeTeam); + openedForCombo = appState.activeCombo; + } + + comboGroups(names).forEach(([teamId, combos]) => { + const team = teamById(teamId); + const open = !!q || openTeams.has(teamId); + const group = document.createElement('div'); + group.className = 'combo-group' + (open ? ' open' : '') + (teamId === activeTeam ? ' has-active' : ''); + + const head = document.createElement('button'); + head.type = 'button'; + head.className = 'combo-group-head'; + head.setAttribute('aria-expanded', String(open)); + const left = document.createElement('span'); + left.className = 'team-item-left'; + const title = document.createElement('span'); + title.textContent = team ? team.name : 'No team'; + left.append(teamAvatars(team ? [team.slot1, team.slot2, team.slot3] : ['', '', '']), title); + const count = document.createElement('span'); + count.className = 'combo-group-count'; + count.textContent = String(combos.length); + head.append(left, count); + head.title = `${title.textContent}: ${combos.length === 1 ? '1 combo' : `${combos.length} combos`}`; + head.addEventListener('click', () => { + if (openTeams.has(teamId)) openTeams.delete(teamId); + else openTeams.add(teamId); + renderComboList(); + }); + group.appendChild(head); + + if (open) { + const body = document.createElement('div'); + body.className = 'combo-group-body'; + body.setAttribute('role', 'listbox'); + body.setAttribute('aria-label', `${title.textContent} combos`); + combos.forEach(name => body.appendChild(comboItem(name))); + group.appendChild(body); + } + list.appendChild(group); + }); +} +getEl('comboSearch')?.addEventListener('input', renderComboList); + +// --------------------------------------------------------------------------- +// 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(); + flushPendingToast(); + }, + combo_list: (msg) => setComboList(msg.combos, msg.active, msg.overview), + 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); + 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), + 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(); + }); +} + +// --------------------------------------------------------------------------- +// 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(); + +// Per-character move rules (chain length, hold behavior, follow-ups) for move names on the timeline. +fetch('data/ww_characters.json') + .then(res => (res.ok ? res.json() : null)) + .then(doc => { + if (!doc) return; + setWwCharacterData(doc); + refreshTimelineIfLoaded(); + }) + .catch(() => { /* move names fall back to generic rules */ }); + +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..3c4d587 --- /dev/null +++ b/static/app/teams.js @@ -0,0 +1,584 @@ +// Teams page: the team picker, the team editor, the character icon library, and the combo's Team dropdown. + +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; +} + +/** Re-render the team and character panels after the editor payload changes. */ +function renderWwPanels() { + renderWwTeamEditor(); + renderWwCharacterEditor(); + renderWwDashPreview(); +} + +// ----- 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(); + renderTeamPicker(); + 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(); + renderTeamPicker(); + renderTeamSlotsEditor(); +} + +function teamComboCounts() { + const counts = {}; + (appState.overview || []).forEach(r => { if (r.team_id) counts[r.team_id] = (counts[r.team_id] || 0) + 1; }); + return counts; +} + +function teamAvatars(slots) { + const wrap = document.createElement('span'); + wrap.className = 'avatars'; + slots.forEach(key => wrap.appendChild(makeAvatar(key, 'avatar-sm'))); + return wrap; +} + +/** The Teams page dropdown: shows the team being edited, lists every team with its portraits. */ +function renderTeamPicker() { + const btnAvatars = getEl('teamPickerAvatars'); + const menu = getEl('teamMenu'); + if (!btnAvatars || !menu) return; + const te = appState.teamEdit; + const counts = teamComboCounts(); + const plural = (n) => (n === 1 ? '1 combo' : `${n} combos`); + + btnAvatars.replaceChildren(...teamAvatars(te.slots).childNodes); + getEl('teamPickerName').textContent = te.id ? (te.name || 'Team') : 'New team'; + getEl('teamPickerMeta').textContent = te.id ? plural(counts[te.id] || 0) : `${appState.wwTeams.length} teams`; + + menu.replaceChildren(); + if (appState.wwTeams.length === 0) { + const p = document.createElement('p'); + p.className = 'muted small menu-empty'; + p.textContent = 'No teams yet. Use New team to make one.'; + menu.appendChild(p); + return; + } + appState.wwTeams.forEach(t => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'menu-item team-item' + (t.id === te.id ? ' on' : ''); + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', String(t.id === te.id)); + const left = document.createElement('span'); + left.className = 'team-item-left'; + const name = document.createElement('span'); + name.textContent = t.name; + left.append(teamAvatars([t.slot1, t.slot2, t.slot3]), name); + const count = document.createElement('span'); + count.className = 'muted small'; + count.textContent = plural(counts[t.id] || 0); + item.append(left, count); + item.addEventListener('click', () => { + closePickers(); + editTeam(t.id); + }); + menu.appendChild(item); + }); +} + +getEl('teamPicker')?.addEventListener('click', () => { + const menu = getEl('teamMenu'); + const open = menu.hidden; + closePickers(); + menu.hidden = !open; + getEl('teamPicker').setAttribute('aria-expanded', String(open)); + if (open) menu.querySelector('.menu-item.on, .menu-item')?.focus(); +}); + +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); + renderTeamPicker(); + }); + + 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(); + } + }); + }); +} + +// ----- 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(); + renderTeamPicker(); + 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..b58ab17 --- /dev/null +++ b/static/app/timeline.js @@ -0,0 +1,1324 @@ +// 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. + 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 new file mode 100644 index 0000000..fffe076 --- /dev/null +++ b/static/characters.css @@ -0,0 +1,279 @@ +/* Characters page. Colors match style.css (the main tracker page). */ +:root { + /* 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; + --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: 'IBM Plex Sans', '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 var(--line-strong); + 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 var(--line-strong); + 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 var(--line); +} +.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 #rawBtn { margin-left: auto; } + +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { padding: 3px 10px; border-radius: 999px; font-size: 13px; } +.chip.on { background: #152E38; border-color: var(--accent-blue); color: var(--text-primary); } + +.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: #152E38; } + +.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 var(--line); 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 (key inputs only) --- */ +.keycap { + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + border: 1px solid var(--line-strong); + border-bottom-width: 3px; + border-radius: 5px; + padding: 1px 7px; + white-space: nowrap; + background: var(--bg-hover); +} +.plus { color: var(--text-secondary); font-size: 11px; margin: 0 5px; } +.badge { font-size: 12px; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--line-strong); color: var(--text-secondary); } +.badge.ok { border-color: var(--accent-green); color: var(--accent-green); } +.move-card { background: var(--bg-primary); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; } +.move-card h3 { margin: 0 0 10px; font-size: 15px; } +.move-card p { margin: 6px 0 0; } +.move-card.notes p { color: #cfd5dc; } +.hits { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 8px; } +.hits li { display: inline-flex; align-items: center; gap: 8px; background: var(--bg-secondary); border-radius: 8px; padding: 5px 10px 5px 6px; } +.keycap.hit { color: var(--accent-blue); } +.entries, .follow { margin: 8px 0 0 18px; display: flex; flex-direction: column; gap: 4px; } +.moves { border-collapse: collapse; width: 100%; } +.moves td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; } +.moves td.inp { width: 1%; white-space: nowrap; } +.other-moves { margin-top: 12px; font-size: 13px; } +.other-moves h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-secondary); margin: 0 0 4px; } + +/* --- Rotations --- */ +.detail h3 { font-size: 15px; margin: 18px 0 10px; } +.detail h3:first-child { margin-top: 0; } +.team { + display: grid; + grid-template-columns: 1fr 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: #152E38; color: #9BE6F7; border-radius: 4px; padding: 0 6px; } +.tag.subtle { background: var(--bg-hover); color: var(--text-primary); } +.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 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; } +.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: #152E38; color: #9BE6F7; } +.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; } +} + +/* 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 new file mode 100644 index 0000000..149f645 --- /dev/null +++ b/static/characters.html @@ -0,0 +1,36 @@ + + + + + + + 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..a0c95bc --- /dev/null +++ b/static/characters.js @@ -0,0 +1,478 @@ +// Characters page: each character's key-input moves (static/data/ww_characters.json, drafted from +// encore.moe and checked by hand) and community team rotations (AntoCrasher's compilation + +// rotation transcripts, served by ui_server.py under /api/ww/). + +// 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 MOVES_URL = 'data/ww_characters.json'; + +// 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, teams: [...]}] + rotationsSource: '', + mine: [], // nameTokens() of characters saved in the tracker + element: '', + query: '', + selectedId: null, + tab: 'moves', + moves: new Map(), // character id -> entry from ww_characters.json + transcripts: new Map(), + tracker: 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 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'); + } + }, + }); +} + +// --------------------------------------------------------------------------- +// 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 }); + } + } + return { main, featured }; +} + +function selectCharacter(id, { pushHash = true } = {}) { + state.selectedId = id; + if (pushHash) history.replaceState(null, '', `#${id}`); + renderRoster(); + if (!state.roster.find((x) => x.id === id)) return; + renderDetail(); + if (window.innerWidth < 900) $('detail').scrollIntoView({ block: 'start', behavior: 'smooth' }); +} + +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(c) : renderRotations(c); + $('detail').innerHTML = detailHeader(c) + body; +} + +// --- Moves (key inputs only; from static/data/ww_characters.json) -------------- + +const CHAIN_AFTER = { + intro: 'the Intro (swap in)', skill: 'the Resonance Skill (E)', liberation: 'the Liberation (R)', + tune_break: 'a Tune Break (F)', heavy: 'a Heavy Attack', dodge: 'a Dodge Counter', midair: 'a Mid-air Attack', +}; + +function keycap(input) { + if (!input) return ''; + return input.split(/,\s*/).map((k) => `${esc(k.toUpperCase())}`).join('then'); +} + +function renderMoves(c) { + const m = state.moves.get(c.id); + if (!m) return '

No move data for this character yet.

'; + const b = m.basic || {}; + const hits = (b.hit_names || []).map((name, i) => + `
  • A${i + 1}${esc(name)}
  • `).join(''); + const hold = b.hold_lmb === 'chain' + ? 'keeps the Basic chain going' + : `${esc(b.hold_label || 'Heavy')} (Heavy Attack)`; + const entries = Object.entries(m.chain_entry || {}) + .filter(([k]) => CHAIN_AFTER[k]) + .map(([k, stage]) => `
  • LMB right after ${esc(CHAIN_AFTER[k])} starts at A${stage}
  • `).join(''); + + const withInput = (m.moves || []).filter((x) => x.input); + const other = (m.moves || []).filter((x) => !x.input); + const moveRows = withInput.map((x) => + `${keycap(x.input)}${esc(x.name)}${x.type ? ` ${esc(x.type)}` : ''}`).join(''); + const otherByType = {}; + other.forEach((x) => { (otherByType[x.type || 'Other'] ||= []).push(x.name); }); + const otherHtml = Object.entries(otherByType).map(([t, names]) => + `

    ${esc(t)}: ${names.map(esc).join(', ')}

    `).join(''); + const follow = (m.followups || []).filter((f) => !f.basic_stage).map((f) => + `
  • ${keycap(f.press)} right after ${esc(f.after)} casts ${esc(f.gives)}
  • `).join(''); + + return `
    + ${m.reviewed ? 'Checked by hand' : 'Auto-drafted, not checked yet'} + From encore.moe, trimmed to key inputs +
    +
    +

    ${esc(b.name || 'Basic Attack')} ${b.hits ? `${b.hits}-hit chain` : 'chain length unknown'}

    + ${hits ? `
      ${hits}
    ` : ''} +

    HOLD LMB ${hold}

    + ${entries ? `` : ''} +
    + ${m.notes ? `

    Notes

    ${esc(m.notes)}

    ` : ''} +
    +

    Moves

    + ${moveRows}
    + ${otherHtml ? `

    Other named moves

    ${otherHtml}
    ` : ''} +
    + ${follow ? `

    Follow-ups

    ` : ''}`; +} + +// --- Rotations ---------------------------------------------------------------- + +function teamRow(t, idx, showMain) { + const video = safeUrl(t.video); + const hasTranscript = !!safeUrl(t.transcript); + 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)}` : ''} +
    +
    +
    + ${hasTranscript ? `` : ''} + ${video ? `Video` : ''} +
    + +
    `; +} + +function renderRotations(c) { + const { main, featured } = rotationsFor(c.name); + state.visibleTeams = [...(main ? main.teams : []), ...featured]; + const all = state.visibleTeams; + let html = ''; + if (main) { + html += `

    ${esc(c.name)} teams

    `; + 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 and rotation transcripts from ${src ? `AntoCrasher's rotation compilation` : "AntoCrasher's rotation compilation"}. 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.tracker) return; + const sent = state.tracker.send('save_combo', { + name, + inputs, + demo_video: safeUrl(t.video), + target_game: 'wuthering_waves', + as_new: true, + }); + 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'); +} + +// --------------------------------------------------------------------------- +// 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); + }); + $('rawBtn').addEventListener('click', downloadRaw); + window.addEventListener('hashchange', () => { + const id = Number(location.hash.slice(1)); + if (id && id !== state.selectedId) selectCharacter(id, { pushHash: false }); + }); +} + +async function load() { + showNotice(''); + let moves; + try { + const res = await fetch(MOVES_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + moves = await res.json(); + } catch (e) { + showNotice(`Couldn't load the character list: ${e.message}`, 'warn'); + return; + } + const chars = Object.values(moves.characters || {}); + chars.sort((a, b) => a.name.localeCompare(b.name)); + state.roster = chars; + state.moves = new Map(chars.map((c) => [c.id, c])); + renderRoster(); + const id = Number(location.hash.slice(1)) || state.selectedId; + if (id) selectCharacter(id, { pushHash: false }); + + try { + const rot = await api('rotations'); + state.rotations = rot.groups; + state.rotationsSource = rot.source; + if (state.selectedId) renderDetail(); + } catch (e) { + showNotice(`Couldn't load team rotations: ${e.message}`, 'warn'); + } +} + +// Saves encore.moe's unedited data to data/encore_raw/ next to the app, for re-drafting the move file +// with tools/ww_build_moves.py. It doesn't change what this page shows. +async function downloadRaw() { + if (!confirm('Download the raw encore.moe data for every character? It is saved as-is in data/encore_raw/ and does not change the moves shown here.')) return; + const btn = $('rawBtn'); + btn.disabled = true; + showNotice('Downloading raw data for every character…'); + try { + const res = await fetch(`/api/ww/scrape-raw`, { method: 'POST' }); + const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + const failed = data.failed && data.failed.length ? ` ${data.failed.length} failed.` : ''; + showNotice(`Saved raw data for ${data.saved} characters in ${data.folder}.${failed} Run tools/ww_build_moves.py to draft new characters from it.`, failed ? 'warn' : 'ok'); + } catch (e) { + showNotice(`Couldn't download the raw data: ${e.message}`, 'warn'); + } finally { + btn.disabled = false; + } +} + +renderFilters(); +bindEvents(); +watchTracker(); +load(); diff --git a/static/data/ww_characters.json b/static/data/ww_characters.json new file mode 100644 index 0000000..13088ec --- /dev/null +++ b/static/data/ww_characters.json @@ -0,0 +1,6529 @@ +{ + "about": "Key-input move data for ComboTracker, drafted from encore.moe by tools/ww_build_moves.py. Entries with \"reviewed\": true were checked by hand; edit this file directly to fix or add details.", + "characters": { + "aalto": { + "id": 1403, + "name": "Aalto", + "element": "Aero", + "weapon": "Pistols", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_12_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": null, + "hit_names": [], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Shift Trick", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Mist Avatar", + "type": "Resonance Skill" + }, + { + "input": "r", + "name": "Flower in the Mist", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Feint Shot", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Dissolving Mist", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Pistols", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "aemeath": { + "id": 1210, + "name": "Aemeath", + "element": "Fusion", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_53_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack - Aemeath", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy Charged" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Aemeath" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Aemeath" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Aemeath" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Aemeath" + }, + { + "input": "e", + "name": "Shared Voyage", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Form Switch", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Sync Strike: Armament Merge", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Sync Strike: Call of Dawn", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Mech", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Mech", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Mid-air Attack - Mech", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Dodge Counter - Mech", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Seraphic Duet: Overture", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Seraphic Duet: Encore", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Tune Rupture Response - Starburst", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Starflux Thrust", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Towards the Daybreak", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Overture of Departure", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Songs Across the Universe", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Debut of Meteoric Radiance", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Silent Protection", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Unlanded Melody", + "type": "Tune Break" + } + ], + "chain_entry": { + "heavy": 2, + "midair": 2, + "dodge": 4, + "intro": 3 + }, + "followups": [ + { + "after": "Heavy Attack - Aemeath: Charged I", + "press": "lmb", + "gives": "Basic Attack - Aemeath Stage 2", + "basic_stage": 2 + }, + { + "after": "Heavy Attack - Aemeath: Charged II", + "press": "lmb", + "gives": "Basic Attack - Aemeath Stage 3", + "basic_stage": 3 + }, + { + "after": "Mid-air Attack - Aemeath", + "press": "lmb", + "gives": "Basic Attack - Aemeath Stage 2", + "basic_stage": 2 + }, + { + "after": "Dodge Counter - Aemeath", + "press": "lmb", + "gives": "Basic Attack - Aemeath Stage 4", + "basic_stage": 4 + }, + { + "after": "Basic Attack - Aemeath Stage 2, 3 & 4", + "press": "e", + "gives": "Sync Strike: Armament Merge" + }, + { + "after": "Basic Attack - Mech Stage 2, 3 & 4", + "press": "e", + "gives": "Sync Strike: Call of Dawn" + }, + { + "after": "Heavy Attack - Mech: Charged I", + "press": "lmb", + "gives": "Basic Attack - Mech Stage 2" + }, + { + "after": "Heavy Attack - Mech: Charged II", + "press": "lmb", + "gives": "Basic Attack - Mech Stage 3" + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Mech Stage 2" + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Mech Stage 4" + }, + { + "after": "Resonance Liberation", + "press": "lmb", + "gives": "Basic Attack - Mech Stage 2" + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Aemeath Stage 3", + "basic_stage": 3 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Mech Stage 3" + }, + { + "after": "Tune Break", + "press": "lmb", + "gives": "Basic Attack Stage 3" + } + ], + "notes": "Holding LMB longer goes from Charged I to Charged II; LMB after Charged I is Stage 2, after Charged II is Stage 3. E right after Basic 2-4, a Heavy or a Dodge Counter casts Sync Strike: Armament Merge and switches to the Mech, whose own 4-hit chain this file does not track separately." + }, + "augusta": { + "id": 1306, + "name": "Augusta", + "element": "Electro", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_51_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Steelclash" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Steelclash" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "space, lmb", + "name": "Mid-air Dodge Counter" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Thunderoar: Backstep" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Thunderoar: Spinslash" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Thunderoar: Uppercut" + }, + { + "input": "hold(lmb)", + "name": "Dodge Counter - Heavy Attack: Steelclash" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Thunderoar: Backstep" + }, + { + "input": "e", + "name": "Warrior's Blade", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Undying Sunlight: Strike", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Undying Sunlight: Leap", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Undying Sunlight: Plunge", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Dodge Counter - Undying Sunlight: Strike", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Sunward Conquest", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Liberation - Sword of Eternal Oath", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Liberation - Sublime is the Sun", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Sublime is the Sun - Sunborne", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Sublime is the Sun - Everbright Protector", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Stride of Goldenflare", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Battlesong of the Unyielding", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": { + "heavy": 2 + }, + "followups": [], + "notes": "With full Prowess, the Heavy becomes Thunderoar: Backstep, then press or release LMB for Spinslash and Uppercut." + }, + "baizhi": { + "id": 1103, + "name": "Baizhi", + "element": "Glacio", + "weapon": "Rectifier", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_6_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Emergency Plan", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Concentration", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Momentary Union", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Remnant Entities", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Overflowing Frost", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Rejuvinating Flow", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "brant": { + "id": 1206, + "name": "Brant", + "element": "Fusion", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_44_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Rhapsodic Riff" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Anchors Aweigh!", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Plunging Attack", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Waves of Acclaims", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Returned from Ashes", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "To the Horizon", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Aflame", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Applaud for Me!", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "The Course is Set!", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "buling": { + "id": 1307, + "name": "Buling", + "element": "Electro", + "weapon": "Rectifier", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_58_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Mountain Over Thunder" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Thunder Over Mountain" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Twin Mountains" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Twin Thunders" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Ghost Gate Omen" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "In Shadow Thunder Stirs", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Flashing Thunder Spell - Harmony", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Flashing Thunder Spell", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Summon and Smite", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Exorcism Spell", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "calcharo": { + "id": 1301, + "name": "Calcharo", + "element": "Electro", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_18_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Extermination Order", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: \"Mercy\"", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Phantom Etching", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Deathblade Gear", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Basic Attack: Hounds Roar", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Wanted Outlaw", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Shadowy Raid", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "camellya": { + "id": 1603, + "name": "Camellya", + "element": "Havoc", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_29_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Pruning" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Valse of Bloom and Blight", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Crimson Blossom", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Budding Mode", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Fervor Efflorescent", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Everblooming", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Twining", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "cantarella": { + "id": 1607, + "name": "Cantarella", + "element": "Havoc", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_34_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Delusive Dive" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Dance with Shadows", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Graceful Step", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Flickering Reverie", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Mirage", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Beneath the Sea", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Flowing Suffocation", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Diffusion", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Cruise", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Ripple", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Tidal Surge", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Gentle Tentacles", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "carlotta": { + "id": 1107, + "name": "Carlotta", + "element": "Glacio", + "weapon": "Pistols", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_32_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": null, + "hit_names": [], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "lmb", + "name": "Basic Attack - Necessary Measures" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Containment Tactics" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Customary Greetings" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Art of Violence", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Chromatic Splendor", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Imminent Oblivion", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Moldable Crystal", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Era of New Wave", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Deconstruction", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Twilight Tango", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Death Knell", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Fatal Finale", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Wintertime Aria", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Closing Remark", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Pistols", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "cartethyia": { + "id": 1409, + "name": "Cartethyia", + "element": "Aero", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_40_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack - Cartethyia", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Cartethyia" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Cartethyia" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Cartethyia" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Cartethyia" + }, + { + "input": "e", + "name": "Sword to Bear Their Names", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Cartethyia", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Fleurdelys", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Mid-air Attack - Fleurdelys", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Fleurdelys", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Upward Cut - Fleurdelys", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Dodge Counter - Fleurdelys", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Sword to Answer Waves' Call", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - May Tempest Break the Tides", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "A Knight's Heartfelt Prayers", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Liberation - A Knight's Heartfelt Prayers", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Liberation - Blade of Howling Squall", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Sword to Mark Tide's Trace", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Wind's Divine Blessing", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "changli": { + "id": 1205, + "name": "Changli", + "element": "Fusion", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_26_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Mid-air Heavy Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Tripartite Flames", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "True Sight: Capture", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack: True Sight - Conquest", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack: True Sight - Charge", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Flaming Sacrifice", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Radiance of Fealty", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Obedience of Rules", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Strategy of Duality", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "chisa": { + "id": 1508, + "name": "Chisa", + "element": "Havoc", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_57_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 2, + "hit_names": [ + "Stage 1", + "Stage 2" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "lmb", + "name": "Rending Lunge" + }, + { + "input": "lmb", + "name": "Death Snip" + }, + { + "input": "lmb", + "name": "Thread Withdrawn" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Severed Facet" + }, + { + "input": "lmb", + "name": "Hanging Finality" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Eye of Unraveling: Retraction" + }, + { + "input": "e", + "name": "Fractured Composition", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Eye of Unraveling", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Serrated Loop", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Chainsaw Mode", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Sawring - Blitz", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Chainsaw Mode - Dodge Counter", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Sawring - Eradication", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Lifethread - Glide", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Moment of Nihility", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Reverberance - Return", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Unraveling - Law Zero", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": { + "liberation": 2, + "intro": 2 + }, + "followups": [ + { + "after": "Basic Attack Stage 2", + "press": "lmb", + "gives": "Rending Lunge" + }, + { + "after": "Rending Lunge", + "press": "lmb", + "gives": "Death Snip" + }, + { + "after": "Rending Lunge", + "press": "lmb", + "gives": "Hanging Finality" + }, + { + "after": "Death Snip", + "press": "lmb", + "gives": "Thread Withdrawn" + }, + { + "after": "Hanging Finality", + "press": "lmb", + "gives": "Death Snip" + }, + { + "after": "Dodge Counter", + "press": "lmb", + "gives": "Rending Lunge" + }, + { + "after": "Dodge Counter - Eye of Unraveling: Retraction", + "press": "lmb", + "gives": "Sawring - Blitz Stage 3" + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Sawring - Blitz Stage 2" + }, + { + "after": "Sawring - Blitz Stage 2: Discordance", + "press": "lmb", + "gives": "Sawring - Blitz Stage 3" + }, + { + "after": "Resonance Liberation", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Sawring - Blitz Stage 2" + } + ], + "notes": "LMB after Basic Stage 2 is Rending Lunge, then Death Snip (on the ground) or Hanging Finality (mid-air). In Chainsaw Mode, LMB is Sawring - Blitz instead." + }, + "chixia": { + "id": 1202, + "name": "Chixia", + "element": "Fusion", + "weapon": "Pistols", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_2_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": null, + "hit_names": [], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Whizzing Fight Spirit", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill: DAKA DAKA!", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill: Boom Boom", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Thermobaric Bullets", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Blazing Flames", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Grand Entrance", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Leaping Flames", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Pistols", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "ciaccona": { + "id": 1407, + "name": "Ciaccona", + "element": "Aero", + "weapon": "Pistols", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_37_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Harmonic Allegro", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Quadruple Downbeat", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Singer's Triple Cadenza", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Symphonic Poem: Tonic", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Roaming with the Wind", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Windcalling Tune", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Pistols", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "danjin": { + "id": 1602, + "name": "Danjin", + "element": "Havoc", + "weapon": "Sword", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_10_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter: Ruby Shades" + }, + { + "input": "e", + "name": "Crimson Fragment", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Carmine Gleam", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Crimson Erosion", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Incinerating Will", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Sanguine Pulse", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Chaoscleave", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack: Scatterbloom", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Crimson Bloom", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Vindication", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Duality", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "denia": { + "id": 1211, + "name": "Denia", + "element": "Fusion", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_64_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack - Stagecraft Form", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Stagecraft Form" + }, + { + "input": "lmb", + "name": "Basic Attack - Breakdown Form" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Stagecraft Form" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Breakdown Form" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Stagecraft Form" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Breakdown Form" + }, + { + "input": "hold(lmb)", + "name": "Mid-air Heavy Attack - Breakdown Form" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Stagecraft Form" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Breakdown Form" + }, + { + "input": "e", + "name": "Bubbles and Baits", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Phantom Bubble - Stagecraft Form", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Beckon - Breakdown Form", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Banish - Breakdown Form", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Erosion Field", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Final Act", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Final Act - Stagecraft Form", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Final Act - Breakdown Form", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Formal Greetings", + "type": "Intro Skill" + }, + { + "input": "", + "name": "It's Been A While!", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Knock Knock", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Unfinished Lies", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Shattered Hours", + "type": "Tune Break" + } + ], + "chain_entry": { + "dodge": 4, + "intro": 4 + }, + "followups": [ + { + "after": "Dodge Counter - Stagecraft Form", + "press": "lmb", + "gives": "Basic Attack - Stagecraft Form Stage 4", + "basic_stage": 4 + }, + { + "after": "Dodge Counter - Breakdown Form", + "press": "lmb", + "gives": "Basic Attack - Breakdown Form Stage 4" + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Stagecraft Form Stage 4", + "basic_stage": 4 + } + ], + "notes": "" + }, + "encore": { + "id": 1203, + "name": "Encore", + "element": "Fusion", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_8_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "lmb", + "name": "Basic Attack: Wooly Strike" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Flaming Woolies", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Energetic Welcome", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Cloudy Frenzy", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack: Cosmos Rupture", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Cosmos Rave", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Basic Attack: Cosmos - Frolicking", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Cosmos: Heavy Attack", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Skill: Cosmos - Rampage", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Cosmos: Dodge Counter", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Woolies Helpers", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Thermal Field", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "galbrena": { + "id": 1208, + "name": "Galbrena", + "element": "Fusion", + "weapon": "Pistols", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_55_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Volley of Death" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Ashfall Barrage" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Blood for Blood" + }, + { + "input": "e", + "name": "Edge Transcended", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Encroach", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Ascent of Malice", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Seraphic Execution", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Flamewing Verdict", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Mid-air Attack - Hellsent Barrage", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Dodge Counter - Purgatory Scourge", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Ravage", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Hellstride", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Hellfire Absolution", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Hellflare Overload", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Ashen Pursuit", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Pistols", + "type": "Tune Break" + } + ], + "chain_entry": { + "heavy": 2, + "dodge": 4, + "intro": 2 + }, + "followups": [ + { + "after": "Basic Attack Stage 4", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Heavy Attack - Volley of Death Stage 1", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Heavy Attack - Volley of Death Stage 2 or 3", + "press": "lmb", + "gives": "Basic Attack Stage 3", + "basic_stage": 3 + }, + { + "after": "Dodge Counter - Blood for Blood", + "press": "lmb", + "gives": "Basic Attack Stage 4", + "basic_stage": 4 + }, + { + "after": "Basic Attack - Seraphic Execution Stage 5", + "press": "lmb", + "gives": "Basic Attack - Seraphic Execution Stage 3" + }, + { + "after": "Heavy Attack - Flamewing Verdict Stage 1", + "press": "lmb", + "gives": "Basic Attack - Seraphic Execution Stage 2" + }, + { + "after": "Heavy Attack - Flamewing Verdict Stage 2 & 3", + "press": "lmb", + "gives": "Basic Attack - Seraphic Execution Stage 3" + }, + { + "after": "Dodge Counter - Purgatory Scourge", + "press": "lmb", + "gives": "Basic Attack - Seraphic Execution Stage 4" + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Heavy Attack - Volley of Death Stage 2" + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + } + ], + "notes": "" + }, + "hiyuki": { + "id": 1108, + "name": "Hiyuki", + "element": "Glacio", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_67_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack - Present Self", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "chain", + "hold_label": "" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Present Self" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Frost Splinter: Present Self" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Present Self" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Present Self" + }, + { + "input": "lmb", + "name": "Basic Attack - Foreclaimed Self" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Foreclaimed Self" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Bitterfrost: Foreclaimed Self" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Foreclaimed Self" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Foreclaimed Self" + }, + { + "input": "e", + "name": "Frostblight", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Present Self", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Frostblight: Jade Cleave", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Frostblight: Petalfall", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Iai Stance", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Basic Attack - Iai", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Foreclaiming", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Foreclaiming: Inward Vision", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Foreclaiming: Blade Liberation", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Frostedge", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Snowlight Blessing", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": { + "dodge": 3, + "skill": 3, + "intro": 3, + "tune_break": 3 + }, + "followups": [ + { + "after": "Dodge Counter - Present Self", + "press": "lmb", + "gives": "Basic Attack - Present Self Stage 3", + "basic_stage": 3 + }, + { + "after": "Heavy Attack - Foreclaimed Self", + "press": "lmb", + "gives": "Basic Attack - Foreclaimed Self Stage 2" + }, + { + "after": "Dodge Counter - Foreclaimed Self", + "press": "lmb", + "gives": "Basic Attack - Foreclaimed Self Stage 3" + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Present Self Stage 3", + "basic_stage": 3 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Present Self Stage 3", + "basic_stage": 3 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Foreclaimed Self Stage 2" + }, + { + "after": "Tune Break", + "press": "lmb", + "gives": "Basic Attack - Present Self Stage 3", + "basic_stage": 3 + }, + { + "after": "Tune Break", + "press": "lmb", + "gives": "Basic Attack - Foreclaimed Self Stage 3" + } + ], + "notes": "Holding LMB keeps the Basic chain going. In Foreclaimed Self the Basic chain is 5 hits and hold(lmb) is Heavy Attack - Foreclaimed Self (Hold Breath); with 300 Dedication, hold(lmb) is Frost Splinter instead." + }, + "hsin": { + "id": 1311, + "name": "Hsin", + "element": "Electro", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_75_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack - Answering Form", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Answering Form" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Answering Form" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Answering Form" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Answering Form: Reign at Ease" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Answering Form: Reign at Ease" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Answering Form" + }, + { + "input": "lmb", + "name": "Basic Attack - Illumining Form" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Illumining Form" + }, + { + "input": "lmb", + "name": "Upward Cut - Illumining Form" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Illumining Form" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Illumining Form" + }, + { + "input": "lmb", + "name": "Basic Attack - Illumining Form: Pillars Aligned" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Illumining Form: Pillars Aligned" + }, + { + "input": "e", + "name": "Heartward by Moon", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Answering Form", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Illumining Form", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Answering Form: Realm Wanderer", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Illumining Form: Beholding All Horizons", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Illumining Form: Pillars Aligned", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Nexus Alight", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Formshift", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Pillars Across Heaven", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Invoked Form: Answering", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Invoked Form: Illumining", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Glance on a Whim", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Intro Skill - Answering Form", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Intro Skill - Answering Form: Manifold Unison", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Intro Skill - Illumining Form", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Intro Skill - Illumining Form: Manifold Unison", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Herself a Thousand Lanterns", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "iuno": { + "id": 1410, + "name": "Iuno", + "element": "Aero", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_48_UI.webp", + "reviewed": true, + "basic": { + "name": "Moonring - Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Moonring - Basic Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Moonring - Dodge Counter" + }, + { + "input": "lmb", + "name": "Moonbow - Basic Attack" + }, + { + "input": "rmb, lmb", + "name": "Moonbow - Dodge Counter" + }, + { + "input": "e", + "name": "Foresight Fugue", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Pulse of Origins", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Closing Refrain", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Unfinished Refrain", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Arc Beyond the Edge", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Lunar Cycle", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Absolute Fullness", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Full Moon Domain", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Beneath Lunar Tides", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Illuminated Manifestation", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "From Gloom to Gleam", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": { + "dodge": 3 + }, + "followups": [ + { + "after": "Moonring - Dodge Counter", + "press": "lmb", + "gives": "Moonring - Basic Attack Stage 3", + "basic_stage": 3 + }, + { + "after": "Moonbow - Dodge Counter", + "press": "lmb", + "gives": "Moonbow - Basic Attack Stage 3" + } + ], + "notes": "Heavy Attack - Flux: Moonring enters Lunar Cycle - New Moon, where LMB becomes the 3-hit Moonbow chain." + }, + "jianxin": { + "id": 1405, + "name": "Jianxin", + "element": "Aero", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_23_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Calming Air", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Chi Counter", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Chi Parry", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Primordial Chi Spiral", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Zhoutian Progress", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Purification Force Field", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Essence of Tao", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Transcendence", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "jingran": { + "id": 1212, + "name": "Jingran", + "element": "Fusion", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_74_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack - Drink Soul", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Drink Soul" + }, + { + "input": "lmb", + "name": "Basic Attack - Devil's Bane" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Nether Dive" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Light Watch" + }, + { + "input": "lmb", + "name": "Shadow Step" + }, + { + "input": "e", + "name": "Malevolent Encounter", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Encroaching Yin", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Scorching Yang", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Netherworld Traverse", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Afterlife's Guide", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Soul Raid", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Stardome Meander", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Burial of Thousand Souls", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Question the Tombs", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Rising Fortune and Ebbing Evil", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": { + "midair": 2, + "dodge": 4, + "skill": 2, + "heavy": 2, + "intro": 2 + }, + "followups": [ + { + "after": "Mid-air Attack", + "press": "lmb", + "gives": "Basic Attack - Drink Soul Stage 2", + "basic_stage": 2 + }, + { + "after": "Mid-air Attack", + "press": "lmb", + "gives": "Basic Attack - Devil's Bane Stage 2" + }, + { + "after": "Dodge Counter - Nether Dive", + "press": "lmb", + "gives": "Basic Attack - Drink Soul Stage 4", + "basic_stage": 4 + }, + { + "after": "Dodge Counter - Light Watch", + "press": "lmb", + "gives": "Basic Attack - Devil's Bane Stage 4" + }, + { + "after": "Shadow Step", + "press": "lmb", + "gives": "Basic Attack - Drink Soul Stage 2", + "basic_stage": 2 + }, + { + "after": "Shadow Step", + "press": "lmb", + "gives": "Basic Attack - Devil's Bane Stage 2" + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Drink Soul Stage 2", + "basic_stage": 2 + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Devil's Bane Stage 2" + }, + { + "after": "Heavy Attack - Soul Raid", + "press": "lmb", + "gives": "Basic Attack - Devil's Bane Stage 2" + }, + { + "after": "Heavy Attack - Stardome Meander", + "press": "lmb", + "gives": "Basic Attack - Drink Soul Stage 2", + "basic_stage": 2 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Drink Soul Stage 2", + "basic_stage": 2 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Devil's Bane Stage 2" + } + ], + "notes": "" + }, + "jinhsi": { + "id": 1304, + "name": "Jinhsi", + "element": "Spectro", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_24_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": null, + "hit_names": [], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Trailing Lights of Eons", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Incarnation", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Illuminous Epiphany", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Unison", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Purge of Light", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Loong's Halo", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Temporal Bender", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "jiyan": { + "id": 1404, + "name": "Jiyan", + "element": "Aero", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_11_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack: Windborne Strike" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack: Abyssal Slash" + }, + { + "input": "space, lmb", + "name": "Mid-Air Attack" + }, + { + "input": "space, lmb", + "name": "Mid-Air Attack: Banner of Triumph" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Windqueller", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Liberation: Emerald Storm - Finale", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Emerald Storm - Prelude", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Qingloong Mode", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Heavy Attack: Lance of Qingloong", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Tactical Strike", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Discipline", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "lingyang": { + "id": 1104, + "name": "Lingyang", + "element": "Glacio", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_14_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "lmb", + "name": "Basic Attack: Feral Roars" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Ancient Arts", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Furious Punches", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Glorious Plunge", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Mid-air Attack: Tail Strike", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Strive: Lion's Vigor", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Lion's Vigor", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Lion Awakens", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Frosty Marks", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "lucilla": { + "id": 1109, + "name": "Lucilla", + "element": "Glacio", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_66_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3 - Unremarkable / Commendable" + ], + "hold_lmb": "chain", + "hold_label": "" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Phantom Frame", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Compensate", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Spotlight", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Oblivion", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Clear As Day", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Basic Attack - Tracing Forms", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Letting It Go", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Mid-air Attack - Reminiscence", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Dodge Counter - Reminiscence", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Clip It", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Clip It: Hard Cut", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Montage", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": { + "midair": 2, + "dodge": 3, + "skill": 2 + }, + "followups": [ + { + "after": "Mid-air Attack", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Resonance Liberation", + "press": "lmb", + "gives": "Basic Attack - Tracing Forms Stage 2" + }, + { + "after": "Resonance Liberation", + "press": "lmb", + "gives": "Basic Attack - Tracing Forms Stage 3" + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Tracing Forms Stage 3" + } + ], + "notes": "Holding LMB runs Stage 1-3 and deploys the Focus Ring on Stage 3: release inside Perfect Focus for Commendable, outside for Unremarkable. After the Liberation, LMB is the Tracing Forms chain." + }, + "lucy": { + "id": 1511, + "name": "Lucy", + "element": "Spectro", + "weapon": "Pistols", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_68_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "lmb", + "name": "Basic Attack - Thread Shredding" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Single Threading" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Dual Threading" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Multi-threading" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Algorithm Compaction" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Algorithm Compaction" + }, + { + "input": "e", + "name": "Protocol Breach", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Payload", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Pulse Interference", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Deadlock", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Hack Response - Data Crash", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Netrunner", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Liberation - Netrunner", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Spoofing Program: Ping", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Spoofing Program: Synapse Burnout", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Spoofing Program: Cripple Movement", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Liberation - Old Net Deep Dive", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Outdated Hallucination", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Countermeasure Program", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Data Crash", + "type": "Tune Break" + } + ], + "chain_entry": { + "heavy": 3, + "midair": 2, + "dodge": 4, + "skill": 2 + }, + "followups": [ + { + "after": "Heavy Attack Stage 1", + "press": "lmb", + "gives": "Basic Attack Stage 3", + "basic_stage": 3 + }, + { + "after": "Mid-air Attack", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Dodge Counter", + "press": "lmb", + "gives": "Basic Attack Stage 4", + "basic_stage": 4 + }, + { + "after": "Heavy Attack - Single Threading", + "press": "lmb", + "gives": "Mid-air Attack - Algorithm Compaction" + }, + { + "after": "Heavy Attack - Single Threading", + "press": "lmb", + "gives": "Basic Attack - Thread Shredding Stage 2" + }, + { + "after": "Heavy Attack - Dual Threading", + "press": "lmb", + "gives": "Heavy Attack - Multi-threading" + }, + { + "after": "Heavy Attack - Dual Threading", + "press": "lmb", + "gives": "Basic Attack - Thread Shredding Stage 2" + }, + { + "after": "Heavy Attack - Multi-threading", + "press": "lmb", + "gives": "Basic Attack - Thread Shredding Stage 2" + }, + { + "after": "Mid-air Attack - Algorithm Compaction", + "press": "lmb", + "gives": "Basic Attack - Thread Shredding Stage 2" + }, + { + "after": "Dodge Counter - Algorithm Compaction", + "press": "lmb", + "gives": "Basic Attack - Thread Shredding Stage 3" + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Thread Shredding Stage 2" + } + ], + "notes": "" + }, + "lumi": { + "id": 1504, + "name": "Lumi", + "element": "Electro", + "weapon": "Broadblade", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_30_UI.webp", + "reviewed": false, + "basic": { + "name": "Yellow Light: Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Yellow Light: Basic Attack" + }, + { + "input": "space, lmb", + "name": "Yellow Light: Plunging Attack" + }, + { + "input": "lmb", + "name": "Red Light: Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Red Light: Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Red Light: Plunging Attack" + }, + { + "input": "rmb, lmb", + "name": "Red Light: Dodge Counter" + }, + { + "input": "e", + "name": "Searchlight Service", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Pounce", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Rebound", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Energized Pounce", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Energized Rebound", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Squeakie Express", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Special Delivery", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Escorting", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "lupa": { + "id": 1207, + "name": "Lupa", + "element": "Fusion", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_46_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Wolf's Gnawing" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Wolf's Claw" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Firestrike" + }, + { + "input": "space, lmb", + "name": "Plunging Attack" + }, + { + "input": "lmb", + "name": "Basic Attack - Starfall" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Shewolf's Hunt", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Feral Fang", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Dance With the Wolf", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Dance With the Wolf: Climax", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Set the Arena Ablaze", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Fire-Kissed Glory", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Resonance Skill - Foebreaker", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Burning Matchpoint", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Try Focusing, Eh?", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Nowhere to Run!", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Stand by Me, Warrior", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "luuk herssen": { + "id": 1510, + "name": "Luuk Herssen", + "element": "Spectro", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_54_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Reunion of All the Fallen", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Golden Reflux", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Aureole of Execution", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Aureole of Execution: Ring", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Aureole of Execution: Breach", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Aureole of Execution: Glare", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Golden Impale", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Ichor Deposit", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Mid-air Attack - Gavel of Earthshaker", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Ichor Blade", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Rewritten in Winter's Margins", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Before Injection of Dawn", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Bow to the Last Light", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Silent Debate of Light", + "type": "Tune Break" + } + ], + "chain_entry": { + "dodge": 3 + }, + "followups": [ + { + "after": "Dodge Counter", + "press": "lmb", + "gives": "Basic Attack Stage 3", + "basic_stage": 3 + } + ], + "notes": "" + }, + "lynae": { + "id": 1509, + "name": "Lynae", + "element": "Spectro", + "weapon": "Pistols", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_60_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "chain", + "hold_label": "" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "lmb", + "name": "Basic Attack - Spark Collision" + }, + { + "input": "lmb", + "name": "Kaleidoscopic Parade - Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Kaleidoscopic Parade - Ground Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Kaleidoscopic Parade - Mid-air Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Kaleidoscopic Parade - Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Kaleidoscopic Parade - Dodge Counter" + }, + { + "input": "e", + "name": "Lynae-Style Palettes", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Additive Color", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Polychrome Leap", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Basic Attack - Iridescent Splash", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Basic Attack - Visual Impact", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Tune Rupture Response - Spectral Analysis", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Prismatic Overblast", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Basic Attack - To a Vivid Tomorrow!", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Time to Show Some Colors!", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Let's Hit the Road!", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Spectral Analysis", + "type": "Tune Break" + } + ], + "chain_entry": { + "skill": 2 + }, + "followups": [ + { + "after": "Kaleidoscopic Parade - Dodge Counter", + "press": "lmb", + "gives": "Kaleidoscopic Parade - Basic Attack Stage 2" + } + ], + "notes": "Holding LMB keeps the chain going; with full Overflow it charges Spark Collision instead. In Kaleidoscopic Parade the Basic chain is 5 hits and hold(lmb) is the Ground Heavy Attack." + }, + "mornye": { + "id": 1209, + "name": "Mornye", + "element": "Fusion", + "weapon": "Broadblade", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_61_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "lmb", + "name": "Basic Attack - Wide Field Observation Mode" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Wide Field Observation Mode" + }, + { + "input": "e", + "name": "Resolution", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Expectation Error", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Optimal Solution", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Distributed Array", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Geopotential Shift", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Wide Field Observation Mode", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Syntony Field", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Inversion", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Tune Rupture Response - Particle Jet", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Critical Protocol", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Convergence", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Recursion", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Decoupling", + "type": "Tune Break" + } + ], + "chain_entry": { + "midair": 3, + "dodge": 2 + }, + "followups": [ + { + "after": "Dodge Counter", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + } + ], + "notes": "In Wide Field Observation Mode the Basic chain is 3 hits. LMB during the Expectation Error parry exits it with Basic Stage 2." + }, + "mortefi": { + "id": 1204, + "name": "Mortefi", + "element": "Fusion", + "weapon": "Pistols", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_13_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": null, + "hit_names": [], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Passionate Variation", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill: Fury Fugue", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Violent Finale", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Marcato", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Dissonance", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Rage Transposition", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Pistols", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "phoebe": { + "id": 1506, + "name": "Phoebe", + "element": "Spectro", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_45_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "hold(lmb)", + "name": "Mid-air Heavy Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "To Where Light Shines", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Ring of Mirrors", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Starflash", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Dawn of Enlightenment", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Golden Grace", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Attentive Heart", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "Inside the Ring of Mirrors, Basic Attack becomes Chamuel's Star (also 3 hits)." + }, + "phrolova": { + "id": 1608, + "name": "Phrolova", + "element": "Havoc", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_41_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "lmb", + "name": "Scarlet Coda" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Whispers in a Fleeting Dream", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Movement of Fate and Finality", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Murmurs in a Haunting Dream", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Waltz of Forsaken Depths", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Basic Attack - Hecate", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Enhanced Attack - Hecate: Strings", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Enhanced Attack - Hecate: Winds", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Enhanced Attack - Hecate: Cadenza", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Curtain Call", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Suite of Quietus", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Suite of Immortality", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Unfinished Piece", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": { + "heavy": 2, + "dodge": 3, + "intro": 3 + }, + "followups": [ + { + "after": "Heavy Attack", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Dodge Counter", + "press": "lmb", + "gives": "Basic Attack Stage 3", + "basic_stage": 3 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack Stage 3", + "basic_stage": 3 + } + ], + "notes": "" + }, + "qingxiao": { + "id": 1413, + "name": "Qingxiao", + "element": "Aero", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_73_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack - Stringblade", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "chain", + "hold_label": "" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Stringblade" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Stringblade" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Stringblade" + }, + { + "input": "space, lmb", + "name": "Plunging Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Stringblade" + }, + { + "input": "lmb", + "name": "Sword Glide" + }, + { + "input": "e", + "name": "Severing Note", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Severing Note: Judgement", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Severing Note: Ascendant", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Ephemeral Transcendence", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Dodge Counter - Ephemeral Transcendence", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Heaven's Reckoning: Ephemeral Transcendence", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Sword Step", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Billows Beneath Heaven", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Tonality Shift", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Lingering Song", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Draw and Sunder", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "qiuyuan": { + "id": 1411, + "name": "Qiuyuan", + "element": "Aero", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_56_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Through the Groves", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Undaunted Wayfarer", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Thus Spoke the Blade: Inkwash", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Sundering Strike", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Attack the Must-Defend", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Strike Before Ready", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "rebecca": { + "id": 1308, + "name": "Rebecca", + "element": "Electro", + "weapon": "Pistols", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_69_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack - Huntress", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Huntress" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Huntress" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Eat Lead!: Huntress" + }, + { + "input": "space, lmb", + "name": "Mid-air Plunging Attack - Huntress" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Huntress" + }, + { + "input": "lmb", + "name": "Tactical Dodge - Huntress" + }, + { + "input": "lmb", + "name": "Basic Attack - Guts" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Guts" + }, + { + "input": "space, lmb", + "name": "Mid-air Plunging Attack - Guts" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Guts" + }, + { + "input": "lmb", + "name": "Tactical Dodge - Guts" + }, + { + "input": "e", + "name": "Tactical Tweaks", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - It's Big Boomin' Time!", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Come 'n' Get Me!", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Hack Response - Meltdown", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Rat-tat-tat!: Huntress", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Bang-bang-bang!: Guts", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Party 'til Dawn!", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "BOOM! Fireworks!", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "My Turn!", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Yo, It's Big Boomin' Time!", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Hey, Leadhead, Come 'n' Get Me!", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Preem Choom", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Hack - Meltdown", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "roccia": { + "id": 1606, + "name": "Roccia", + "element": "Havoc", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_33_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Acrobatic Trick", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Basic Attack - Real Fantasy", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Commedia Improvviso!", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Pero, Help", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Applause, Please!", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "rover: aero": { + "id": 1406, + "name": "Rover: Aero", + "element": "Aero", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_4_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack - Razor Wind" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Illusion Breaker", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Awakening Gale", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Skyfall Severance", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Mid-air Attack - Cloudburst Dance", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Unbound Flow", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Omega Storm", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Relentless Squall", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Storm's Echo", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [ + { + "after": "Basic Attack Stage 3", + "press": "lmb", + "gives": "Razor Wind" + } + ], + "notes": "" + }, + "rover: electro": { + "id": 1309, + "name": "Rover: Electro", + "element": "Electro", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_4_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "lmb", + "name": "Parry Stance" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Havoc" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "space, lmb", + "name": "Mid-air Dodge Counter" + }, + { + "input": "e", + "name": "Thunderclap", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Overshock", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Ultimate Tactics", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Thunderous Fury", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Rumbling Thunders", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break - Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [ + { + "after": "Basic Attack - Riposte Strike", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Basic Attack - Riposte Strike: Crumble", + "press": "lmb", + "gives": "Mid-air Attack - Havoc" + }, + { + "after": "Overshock", + "press": "lmb", + "gives": "Normal Attack Stage 4" + } + ], + "notes": "" + }, + "rover: havoc": { + "id": 1604, + "name": "Rover: Havoc", + "element": "Havoc", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_5_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Wingblade", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Devastation", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Umbra", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Deadening Abyss", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Instant of Annihilation", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Soundweaver", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "rover: spectro": { + "id": 1501, + "name": "Rover: Spectro", + "element": "Spectro", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_4_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack: Resonance" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack: Aftertune" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Resonating Slashes", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill: Resonating Spin", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Basic Attack: Resonating Echoes", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Echoing Orchestra", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Waveshock", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Instant", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "sanhua": { + "id": 1102, + "name": "Sanhua", + "element": "Glacio", + "weapon": "Sword", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_7_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Eternal Frost", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Detonate", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Glacial Gaze", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Freezing Thorns", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Silversnow", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "shorekeeper": { + "id": 1505, + "name": "Shorekeeper", + "element": "Spectro", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_28_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Chaos Theory", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Flare Star Butterfly", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Illation", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Transmutation", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "End Loop", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Proof of Existence", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Enlightenment", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Discernment", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Binary Butterfly", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": { + "midair": 2 + }, + "followups": [], + "notes": "Holding LMB enters Unbound Form, which drains STA." + }, + "sigrika": { + "id": 1412, + "name": "Sigrika", + "element": "Aero", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_65_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "lmb", + "name": "Basic Attack - Elucidated" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Decipher" + }, + { + "input": "space, lmb", + "name": "Mid-air Dodge Counter" + }, + { + "input": "e", + "name": "Royan Close Quarters Combat", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "BOOMY BOOM!", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "BIG BOOMY BOOM!", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Soliskin to the Aid", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Schemata of Runes", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Runic Outburst", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Runic Chain Whip", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Runic Soliskin", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Forte Circuit - Learn My True Name", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Where Trust Leads Me!", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Solsworn Etymology", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "In This Very Moment", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": { + "heavy": 2, + "dodge": 4, + "intro": 2 + }, + "followups": [ + { + "after": "Heavy Attack", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Dodge Counter", + "press": "lmb", + "gives": "Basic Attack Stage 4", + "basic_stage": 4 + }, + { + "after": "Mid-air Dodge Counter", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "BOOMY BOOM!", + "press": "lmb", + "gives": "Basic Attack Stage 3", + "basic_stage": 3 + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + } + ], + "notes": "" + }, + "suisui": { + "id": 1110, + "name": "Suisui", + "element": "Glacio", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_71_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack - Zephyr Stance", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "chain", + "hold_label": "" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Zephyr Stance" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Zephyr Stance" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Zephyr Stance" + }, + { + "input": "e", + "name": "Vernal Screen", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Zephyr Stance", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Awakening Spring", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Enrichment", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Drizzle Stance", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Dodge - Parting Mist", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Basic Attack - Drizzle Stance", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Drizzle Stance", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Illuminating Dew", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Swallow's Cut", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Song of Thoroughfare", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Tinkling Jade", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Rippling Waters", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": { + "dodge": 4 + }, + "followups": [ + { + "after": "Dodge Counter - Zephyr Stance", + "press": "lmb", + "gives": "Basic Attack - Zephyr Stance Stage 4", + "basic_stage": 4 + } + ], + "notes": "Zephyr Stance. Holding LMB keeps the chain going. Drizzle Stance has its own Basic and Heavy Attack." + }, + "suoming": { + "id": 1312, + "name": "Suoming", + "element": "Electro", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_76_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "chain", + "hold_label": "" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "lmb", + "name": "Basic Attack - Unfurled Canopy" + }, + { + "input": "lmb", + "name": "Basic Attack - Unfurled Canopy: Whirling Thunder" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Unfurled Canopy" + }, + { + "input": "e", + "name": "Furled Canopy: Rift Cleaver", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Unfurled Canopy: Crimson Gleam", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Furled Canopy: Sealed Delusion", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Unfurled Canopy: Unforsaken Mind", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Basic Attack - Umbral Canopy: Engraved Heart", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Umbral Canopy: Miasma Lock", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Furled Canopy: Flash Rift", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Intro Skill - Unfurled Canopy: Thunder Rending", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Intro Skill - Furled Canopy: Sealed Delusion (Unison)", + "type": "Intro Skill" + }, + { + "input": "", + "name": "Intro Skill - Unfurled Canopy: Whirling Thunder (Unison)", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Canopy Rumble", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break - Sword", + "type": "Tune Break" + } + ], + "chain_entry": { + "skill": 2 + }, + "followups": [ + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack Stage 2", + "basic_stage": 2 + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Unfurled Canopy Stage 3" + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack Stage 3", + "basic_stage": 3 + }, + { + "after": "Resonance Skill", + "press": "lmb", + "gives": "Basic Attack - Unfurled Canopy Stage 4" + }, + { + "after": "Intro Skill", + "press": "lmb", + "gives": "Basic Attack - Unfurled Canopy: Whirling Thunder Stage 2" + }, + { + "after": "Intro Skill - Unfurled Canopy: Whirling Thunder (Unison)", + "press": "lmb", + "gives": "Basic Attack - Unfurled Canopy: Whirling Thunder Stage 2" + }, + { + "after": "Basic Attack - Unfurled Canopy Stage 4", + "press": "lmb", + "gives": "Basic Attack - Unfurled Canopy Stage 2" + } + ], + "notes": "" + }, + "taoqi": { + "id": 1601, + "name": "Taoqi", + "element": "Havoc", + "weapon": "Broadblade", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_9_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "lmb", + "name": "Strategic Parry" + }, + { + "input": "space, lmb", + "name": "Mid-Air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Fortified Defense", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Rocksteady Shield", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Timed Counters", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Unmovable", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Defense Formation", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Iron Will", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Broadblade", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "verina": { + "id": 1503, + "name": "Verina", + "element": "Spectro", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_3_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "hold(lmb)", + "name": "Mid-air Heavy Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Botany Experiment", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Starflower Blooms", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Mid-air Attack: Starflower Blooms", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Arboreal Flourish", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Photosynthesis Mark", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Verdant Growth", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Blossom", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "xiangli yao": { + "id": 1305, + "name": "Xiangli Yao", + "element": "Electro", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_25_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Deduction", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Decipher", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Law of Reigns", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Mid-air Attack - Revamp", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Cogitation Model", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Principle", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Chain Rule", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "yangyang": { + "id": 1402, + "name": "Yangyang", + "element": "Aero", + "weapon": "Sword", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_1_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack: Zephyr Song" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Zephyr Domain", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack: Stormy Strike", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Mid-air Attack: Feather Release", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Wind Spirals", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Cerulean Song", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Whispering Breeze", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Sword", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "yangyang: xuanling": { + "id": 1610, + "name": "Yangyang: Xuanling", + "element": "Havoc", + "weapon": "Sword", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_70_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack - Azure Sword Stance", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "chain", + "hold_label": "" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack - Azure Sword Stance" + }, + { + "input": "lmb", + "name": "Basic Attack - Feather Sword Stance" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Azure Sword Stance" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack - Feather Sword Stance" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Azure Sword Stance" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter - Feather Sword Stance" + }, + { + "input": "e", + "name": "Feather's Edge", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Sword Stance Switch", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Sword Stance Switch: Azure", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Sword Stance Switch: Feather", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Resonance Skill - Sword Stance Flow: Azure", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Sword Stance Flow: Feather", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Azure Sword Stance", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Heavy Attack - Feather Sword Stance", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Mid-air Attack - Feather Fall", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Hush of a Thousand Voices", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Skybound Feather", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "As the Wind Wills", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break - Sword", + "type": "Tune Break" + } + ], + "chain_entry": { + "midair": 2, + "dodge": 3 + }, + "followups": [], + "notes": "Holding LMB keeps the chain going; with full Azure Plume it casts the stance Heavy Attack instead. E switches between Azure and Feather Sword Stance (each a 4-hit chain)." + }, + "yinlin": { + "id": 1302, + "name": "Yinlin", + "element": "Electro", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_17_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": null, + "hit_names": [], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Magnetic Roar", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Execution Mode", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Electromagnetic Blast", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Lightning Execution", + "type": "Resonance Skill" + }, + { + "input": "r", + "name": "Thundering Wrath", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Raging Storm", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Strategist", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [ + { + "after": "Magnetic Roar", + "press": "e", + "gives": "Lightning Execution" + } + ], + "notes": "" + }, + "youhu": { + "id": 1106, + "name": "Youhu", + "element": "Glacio", + "weapon": "Gauntlets", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_31_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack: Frostfall" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Scroll Divination", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Lucky Draw", + "type": "Resonance Skill" + }, + { + "input": "r", + "name": "Fortune's Favor", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Scroll of Wonders", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Timeless Classics", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "yuanwu": { + "id": 1303, + "name": "Yuanwu", + "element": "Electro", + "weapon": "Gauntlets", + "rarity": 4, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_15_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 5, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4", + "Stage 5" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Leihuang Master", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Thunder Wedge", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Rumbling Spark", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Thunder Uprising", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Lightning Infused", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Blazing Might", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Thunder Bombardment", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Lightning Manipulation", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + }, + "zani": { + "id": 1507, + "name": "Zani", + "element": "Spectro", + "weapon": "Gauntlets", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_38_UI.webp", + "reviewed": true, + "basic": { + "name": "Basic Attack", + "hits": 4, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3", + "Stage 4 (after Breakthrough)" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Restless Watch", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Standard Defense Protocol", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Sunburst", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heliacal Ember", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Between Dawn and Dusk", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Rekindle", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "The Last Stand", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Immediate Execution", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Beacon For the Future", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Gauntlets", + "type": "Tune Break" + } + ], + "chain_entry": { + "heavy": 3, + "midair": 3, + "skill": 3 + }, + "followups": [], + "notes": "After Stage 3, LMB at the right time casts Breakthrough, then LMB again for Stage 4. With 30+ Blaze, Basic Attack is replaced by Heavy Slash - Daybreak." + }, + "zhezhi": { + "id": 1105, + "name": "Zhezhi", + "element": "Glacio", + "weapon": "Rectifier", + "rarity": 5, + "icon": "https://api.encore.moe/resource/Data/Game/Aki/UI/UIResources/Common/Image/IconRoleHead80/T_IconRoleHead80_27_UI.webp", + "reviewed": false, + "basic": { + "name": "Basic Attack", + "hits": 3, + "hit_names": [ + "Stage 1", + "Stage 2", + "Stage 3" + ], + "hold_lmb": "heavy", + "hold_label": "Heavy" + }, + "moves": [ + { + "input": "lmb", + "name": "Basic Attack" + }, + { + "input": "hold(lmb)", + "name": "Heavy Attack" + }, + { + "input": "space, lmb", + "name": "Mid-air Attack" + }, + { + "input": "rmb, lmb", + "name": "Dodge Counter" + }, + { + "input": "e", + "name": "Manifestation", + "type": "Resonance Skill" + }, + { + "input": "", + "name": "Heavy Attack - Conjuration", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Stroke of Genius", + "type": "Forte Circuit" + }, + { + "input": "", + "name": "Resonance Skill - Creation's Zenith", + "type": "Forte Circuit" + }, + { + "input": "r", + "name": "Living Canvas", + "type": "Resonance Liberation" + }, + { + "input": "", + "name": "Inklit Spirit", + "type": "Resonance Liberation" + }, + { + "input": "swap in", + "name": "Radiant Ruin", + "type": "Intro Skill" + }, + { + "input": "swap out", + "name": "Carve and Draw", + "type": "Outro Skill" + }, + { + "input": "f", + "name": "Tune Break: Rectifier", + "type": "Tune Break" + } + ], + "chain_entry": {}, + "followups": [], + "notes": "" + } + } +} diff --git a/static/index.html b/static/index.html index d264917..31b0505 100644 --- a/static/index.html +++ b/static/index.html @@ -4,287 +4,437 @@ - Combo Trainer + WuWa Combo Tracker + + - + -
    - - -
    -
    - -

    Combo Input

    -
    -
    - -
    - - -
    +
    - -
    - - - - -
    + + + +
    + +
    +
    + Now practicing +

    Practice

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

    Combo Steps

    +
    + Images + + + Auto scroll + + + Move names + + + Show attempt history + + + Edit steps + + + Collapse chains + + + No fail + + + + +
    +
    + +
    Status: Select a combo to start +
    +
    +
    + +
    +
    +
    + +
    +

    Edit combo

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

    Target game

    -
    -
    - - - - -
    + +
    +

    Record or replay

    +
    + +
    -
    +

    Recording starts when you press your start key and stops on Esc. Macro replay has its own start and stop keys, and Esc always stops it. Keys and delays are set in Settings.

    + - -
    -
    -

    Characters

    -
    + +
    +
    +

    Attempt log

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

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

    +
    +
    +
    + + + + - - + - -
    + + -
    -
    Fastest possible: —
    - -
    -
    Difficulty: —
    -
    Your difficulty: —
    -
    Practical APM: —
    -
    Theoretical max APM: —
    -
    Stats: —
    -
    - - -
    -
    -

    Combo Steps

    -
    - Images - - - Auto scroll - - - Show attempt history - - - Edit steps - - - Collapse chains - - - - -
    -
    -
    - 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) + +
    -
    + + + + + + - + + + + + + + + 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..0e26a20 --- /dev/null +++ b/static/keys.html @@ -0,0 +1,44 @@ + + + + + + + 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..75d9d42 --- /dev/null +++ b/static/keys.js @@ -0,0 +1,48 @@ +// 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 keyEls = new Map(Array.from(document.querySelectorAll('.key')).map((el) => [el.dataset.key, el])); + +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); + } + }); +} + +connectTracker({ + onOpen: () => keyEls.forEach((el) => el.classList.remove('offline')), + onClose: () => { + releaseAll(); + keyEls.forEach((el) => el.classList.add('offline')); + }, + 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); + }, +}); diff --git a/static/shell.css b/static/shell.css new file mode 100644 index 0000000..6e1f0b5 --- /dev/null +++ b/static/shell.css @@ -0,0 +1,946 @@ +/* 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: 256px 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, +.combo-group-head:focus-visible, +.menu-item:focus-visible, +.picker-btn:focus-visible, +.team-card: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; } + +/* Teams page: team dropdown + New team */ +.team-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.team-bar .picker { flex: 0 1 460px; min-width: 0; } +.team-bar .picker-btn { min-width: 0; } +.team-bar .picker-menu { left: 0; right: auto; width: 100%; } +.team-item-left { display: flex; align-items: center; gap: 12px; min-width: 0; } +.team-item-left > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.team-item .avatars .avatar { border-color: var(--ct-surface); } + +.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; } + +/* Dragging a tile's icon drags the tile (to reorder), not a copy of the image */ +.timeline-section img, +.timeline-section svg { + -webkit-user-drag: none; + user-select: none; +} + +/* The tile for the input under the cursor in the Inputs box (see applyEditFocus in app/combos.js). + An outline, so the tile's own border colors keep their meaning. */ +.app .timeline-section .step-edit-focus { + outline: 3px solid var(--ct-accent); + outline-offset: 3px; + box-shadow: 0 0 14px rgba(46, 197, 234, .55); +} +.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: minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.editor-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; } + +/* Collapsible "Edit combo" panel under the timeline */ +.combo-edit:not([open]) { gap: 0; } +.combo-edit-summary { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + list-style: none; + user-select: none; +} +.combo-edit-summary::-webkit-details-marker { display: none; } +.combo-edit-summary::before { + content: ""; + width: 8px; + height: 8px; + border-right: 2px solid var(--ct-muted); + border-bottom: 2px solid var(--ct-muted); + transform: rotate(-45deg); + transition: transform .15s ease; + flex: none; +} +.combo-edit[open] > .combo-edit-summary::before { transform: rotate(45deg); } +.combo-edit-summary:focus-visible { outline: 2px solid var(--ct-accent); outline-offset: 4px; border-radius: 4px; } + +/* In the rail it fills the space between the nav and the status card, scrolling on its own */ +.combo-list-card { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1 1 auto; + min-height: 120px; + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid var(--ct-line); +} +.rail .combo-list-card + .rail-spacer { display: none; } +/* Rail is narrow: smaller, tighter portraits and no step counts so names have room */ +.rail .combo-group-head { padding: 6px 6px; min-height: 40px; gap: 6px; font-size: 13px; } +.rail .combo-group-head .team-item-left { gap: 8px; } +.rail .combo-group-head .avatar { width: 22px; height: 22px; font-size: 9px; margin-right: -9px; border-width: 1.5px; } +.rail .combo-group-head .avatars { padding-right: 9px; } +.rail .combo-group-body { padding: 0 2px 6px 10px; } +.rail .combo-item { min-height: 34px; padding: 5px 8px; font-size: 13px; } +.rail .combo-item-steps { display: none; } +.app.compact .rail .combo-list-card { display: none; } +.app.compact .rail .combo-list-card + .rail-spacer { display: block; } +.editor > .combo-list-card { padding: 10px; border: 1px solid var(--ct-line); border-radius: 14px; background: var(--ct-panel); max-height: 320px; margin: 0; } +.combo-list-tools { display: flex; gap: 8px; padding: 0 0 6px; } +.combo-list-tools .input { flex-grow: 1; min-width: 0; width: auto; } +.combo-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 0; margin: 0 -4px; padding: 0 4px; } + +/* Combo list grouped by team (same rows as the Teams page dropdown) */ +.combo-group { border-radius: 10px; } +.combo-group.open { background: rgba(255, 255, 255, .02); } +.combo-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 8px 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--ct-text); + font: 600 14px var(--ct-font-body); + cursor: pointer; + text-align: left; +} +.combo-group-head:hover { background: var(--ct-surface); transform: none; } +.combo-group.has-active > .combo-group-head { color: var(--ct-accent); } +.combo-group-head .avatars .avatar { border-color: var(--ct-panel); } +.combo-group-count { + display: inline-flex; + align-items: center; + gap: 6px; + font: 500 12px var(--ct-font-mono); + color: var(--ct-muted); +} +.combo-group-count::after { + content: ""; + width: 7px; + height: 7px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(-45deg); + transition: transform .15s ease; +} +.combo-group.open .combo-group-count::after { transform: rotate(45deg); } +.combo-group-body { display: flex; flex-direction: column; gap: 2px; padding: 0 4px 6px 18px; } + +.combo-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-height: 38px; + padding: 6px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ct-text); + font: 500 13.5px 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 { min-width: 0; 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); } + +.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); +} + +/* --------------------------------------------------------------------------- + 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); } + .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 .combo-list-card, +body.timeline-window-view .editor-main > :not(.timeline-section) { display: none !important; } +body.timeline-window-view .editor { display: block; } +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/style.css b/static/style.css index 29731c6..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; @@ -418,6 +354,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; } @@ -461,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; @@ -747,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; @@ -839,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; @@ -923,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; @@ -1007,8 +715,44 @@ button.danger:hover { margin-bottom: 0; } -.time-display, -.stats-display { +/* 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 { color: var(--text-secondary); font-size: 0.9rem; text-align: center; @@ -1483,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; @@ -1510,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); @@ -1617,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; @@ -1680,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; @@ -1716,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; @@ -1770,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); @@ -1839,8 +1484,7 @@ button.danger:hover { } /* Tables */ -.results-table, -.fail-table { +.results-table { max-height: 300px; overflow-y: auto; border: 1px solid #333; @@ -1857,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; @@ -1872,8 +1514,7 @@ button.danger:hover { transition: var(--transition); } -.result-row:hover, -.fail-row:hover { +.result-row:hover { background: var(--bg-hover); } @@ -1974,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, @@ -1983,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; } @@ -2073,3 +1708,94 @@ 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); +} + +/* 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/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/static/ww_moves.js b/static/ww_moves.js new file mode 100644 index 0000000..739ced2 --- /dev/null +++ b/static/ww_moves.js @@ -0,0 +1,157 @@ +// 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 +// +// Per-character rules (chain length, what hold(lmb) does, where LMB picks the chain back up after +// another move) come from static/data/ww_characters.json via setWwCharacterData(). + +const WW_SLOTS = ['1', '2', '3']; + +// basic = hits in the Basic chain before it restarts (0 = unknown, never wraps); hold = what hold(lmb) +// is called (null when holding just continues the Basic chain); entry = { intro|skill|liberation| +// tune_break|heavy|dodge|midair: stage } for "LMB right after that move is Basic Stage N". +const WW_DEFAULT_RULE = { basic: 0, hold: 'Heavy', entry: {} }; +let wwCharacterRules = {}; // normalized name -> rule + +// The in-game Basic chain resets after a pause this long. +const WW_BASIC_RESET_MS = 1000; + +/** "Rover: Spectro" / "Yangyang Xuanling" -> "rover spectro" / "xuanling yangyang" (word order ignored). */ +function wwNameTokens(name) { + return String(name || '').toLowerCase().split(/[^a-z]+/).filter(Boolean).sort().join(' '); +} + +/** Load ww_characters.json ({characters: {key: entry}}) as labeling rules. */ +function setWwCharacterData(doc) { + const rules = {}; + Object.values((doc && doc.characters) || {}).forEach((c) => { + const b = c.basic || {}; + rules[wwNameTokens(c.name)] = { + basic: Number(b.hits) || 0, + hold: b.hold_lmb === 'chain' ? null : (b.hold_label || 'Heavy'), + entry: c.chain_entry || {}, + }; + }); + wwCharacterRules = rules; +} + +// Team names are typed by hand ("Pheobe", "Agusta", "Rover"), so allow one typo, a missing word, +// or swapped letters before falling back to the default rule. +function wwRuleFor(name) { + const t = wwNameTokens(name); + if (!t) return WW_DEFAULT_RULE; + if (wwCharacterRules[t]) return wwCharacterRules[t]; + const keys = Object.keys(wwCharacterRules); + const squash = (s) => s.replace(/ /g, ''); + const close = (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; + }; + const hit = keys.find((k) => close(squash(k), squash(t))) + || keys.find((k) => k.split(' ').some((w) => close(w, squash(t)))); + return hit ? wwCharacterRules[hit] : WW_DEFAULT_RULE; +} + +/** 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 = ''; + let after = ''; // the move LMB would follow (intro, skill, heavy, ...) for chain_entry + const who = (slot) => (slotNames && slotNames[slot]) || `Slot ${slot}`; + const rulesFor = (slot) => wwRuleFor(who(slot)); + + // 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; + after = ''; + } + 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; + const prevAfter = after; + lastKey = key; + fightStarted = true; + after = ''; + + if (key === 'lmb') { + const rule = rulesFor(s); + if (step.type === 'spam') { basicCount = 0; return `${n} Basic spam`; } + if (isHold && rule.hold) { basicCount = 0; after = 'heavy'; return `${n} ${rule.hold}`; } + if (!isHold && prevKey === 'space') { basicCount = 0; after = 'midair'; 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; + after = 'dodge'; + return hits > 1 ? `${n} Dodge Counter? +${hits - 1}` : `${n} Dodge Counter?`; + } + // Picking the chain back up after another move (e.g. LMB after Zani's Skill is Basic 3). + const entry = prevAfter && rule.entry ? Number(rule.entry[prevAfter]) : 0; + if (entry > 0) basicCount = entry - 1; + const text = basicLabel(n, rule, hits); + return isHold ? `${text} (held)` : text; + } + + basicCount = 0; + if (key === 'f') { + if (!wasStarted) return 'Start fight'; + after = 'tune_break'; + return 'Tune Break'; + } + if (WW_SLOTS.includes(key)) { after = 'intro'; return `${who(key)} Intro`; } + if (key === 'e') { after = 'skill'; return isHold ? `${n} Held Skill` : `${n} Skill`; } + if (key === 'q') return `${n} Echo`; + if (key === 'r') { after = 'liberation'; 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, setWwCharacterData, wwRuleFor }; 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/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_cancel_attempt.py b/tests/test_cancel_attempt.py new file mode 100644 index 0000000..a9ff383 --- /dev/null +++ b/tests/test_cancel_attempt.py @@ -0,0 +1,50 @@ +import unittest + +from combo_engine import ComboTrackerEngine +from parser import expanded_ast_from_tokens, split_inputs +from state_store import MemoryStateStore +from states import build_runtime_state + + +def _engine_with_combo(inputs: str) -> tuple[ComboTrackerEngine, list[dict]]: + sent: list[dict] = [] + engine = ComboTrackerEngine(state_store=MemoryStateStore()) + engine.set_emitter(sent.append) + engine.active_combo_name = "_cancel_test" + engine.active_combo_tokens = split_inputs(inputs) + engine.runtime_steps = [build_runtime_state(n) for n in expanded_ast_from_tokens(engine.active_combo_tokens)] + engine.reset_tracking() + return engine, sent + + +def _types(sent: list[dict]) -> list[str]: + return [m.get("type") for m in sent] + + +class CancelAttemptTests(unittest.TestCase): + """Esc mid-wait must tell the UI the wait ended, or its fill animation latches onto the reset timeline.""" + + def test_esc_inside_group_animation_lock_ends_the_wait_animation(self): + engine, sent = _engine_with_combo("f, [q, e, wait(r, 2.9s)], 2") + engine.process_press("f") + engine.process_release("f") + engine.process_press("r") # starts the group's 2.9s animation lock + self.assertIn("wait_begin", _types(sent)) + + sent.clear() + engine.cancel_attempt() + + self.assertIn("wait_end", _types(sent)) + self.assertEqual(engine.current_index, 0) + + def test_esc_with_no_wait_running_sends_no_wait_end(self): + engine, sent = _engine_with_combo("f, q, e") + engine.process_press("f") + engine.process_release("f") + sent.clear() + engine.cancel_attempt() + self.assertNotIn("wait_end", _types(sent)) + + +if __name__ == "__main__": + unittest.main() 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_combo_overview.py b/tests/test_combo_overview.py new file mode 100644 index 0000000..2361f13 --- /dev/null +++ b/tests/test_combo_overview.py @@ -0,0 +1,50 @@ +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 combo list (grouped by team) reads 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[1]["steps"], 3) + + 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_carries_the_overview(self): + engine = _engine_with_two_combos() + self.assertEqual(len(engine.init_payload()["overview"]), 2) + + +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..9b9b27f --- /dev/null +++ b/tests/test_ww_library.py @@ -0,0 +1,172 @@ +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.assertNotIn("thoughts", zani) # strategy and damage commentary is out of scope + 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.assertNotIn("dps", top) # damage is out of scope + 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["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): + DOC = "https://docs.google.com/document/d/ABC_1/edit" + + 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 TRANSCRIPT + + 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.transcript(self.DOC)["sections"][0]["name"], "Opener") + self.lib.transcript(self.DOC) + self.assertEqual(len(self.calls), 1) # second read came from cache + self.fail = True + stale = self.lib.transcript(self.DOC, refresh=True) + self.assertTrue(stale["stale"]) + self.assertEqual(stale["sections"][0]["name"], "Opener") + + def test_api_routes(self): + status, body = handle_api(self.lib, "/api/ww/transcript?url=" + self.DOC) + self.assertEqual((status, body["sections"][0]["name"]), (200, "Opener")) + self.assertEqual(handle_api(self.lib, "/api/ww/nope")[0], 404) + self.assertEqual(handle_api(self.lib, "/api/ww/kit/1507")[0], 404) # kits come from static/data now + self.fail = True + status, body = handle_api(self.lib, "/api/ww/transcript?url=https://docs.google.com/document/d/XYZ/edit") + self.assertEqual(status, 502) + self.assertIn("offline", body["error"]) + + +class ScrapeRawTests(unittest.TestCase): + def test_saves_roster_and_each_kit_unedited(self): + roster = {"roleList": [{"Id": 1507, "Name": "Zani"}, {"Id": 1507, "Name": "Zani"}, {"Id": 9, "Name": "Broken"}]} + + def fetch(url): + if url.endswith("/9"): + raise LibraryError("404") + return json.dumps(KIT if url.endswith("/1507") else roster) + + with tempfile.TemporaryDirectory() as tmp: + result = ww_library.scrape_raw(Path(tmp), fetch=fetch) + self.assertEqual(result["saved"], 1) + self.assertEqual(len(result["failed"]), 1) + saved = json.loads((Path(tmp) / "kit_1507.json").read_text(encoding="utf-8")) + self.assertEqual(saved, KIT) + self.assertTrue((Path(tmp) / "roster.json").exists()) + + +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..3a4618f --- /dev/null +++ b/tests/ww_moves.test.js @@ -0,0 +1,86 @@ +// Run with: node --test tests/ww_moves.test.js +const test = require('node:test'); +const assert = require('node:assert'); +const { createWwMoveLabeler, setWwCharacterData, wwRuleFor } = require('../static/ww_moves.js'); + +// Rules come from the hand-checked move data the app loads. +setWwCharacterData(require('../static/data/ww_characters.json')); + +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'); +}); + +test('lmb after another move picks the chain up where the kit says', () => { + const label = createWwMoveLabeler({ '1': 'Zani', '2': 'Aemeath' }); + assert.strictEqual(label(press('e'), '1'), 'Zani Skill'); + assert.strictEqual(label(press('lmb'), '1'), 'Zani Basic 3'); // Zani's Skill -> Basic Stage 3 + assert.strictEqual(label(press('lmb'), '1'), 'Zani Basic 4'); + assert.strictEqual(label(press('2'), '2'), 'Aemeath Intro'); + assert.strictEqual(label(press('lmb'), '2'), 'Aemeath Basic 3'); // Aemeath's Intro -> Stage 3 + assert.strictEqual(label(press('q'), '2'), 'Aemeath Echo'); + assert.strictEqual(label(press('lmb'), '2'), 'Aemeath Basic 1'); // Echo has no follow-up +}); + +test('hand-typed team names still find their character', () => { + assert.strictEqual(wwRuleFor('Agusta').hold, 'Steelclash'); + assert.strictEqual(wwRuleFor('Pheobe').basic, 3); + assert.strictEqual(wwRuleFor('Yangyang Xuanling').hold, null); + assert.strictEqual(wwRuleFor('Nobody').basic, 0); +}); + +test('Lucilla holds through her Basic chain instead of a Heavy', () => { + const label = createWwMoveLabeler({ '1': 'Lucilla' }); + assert.strictEqual(label({ type: 'hold', input: 'lmb', duration: 400 }, '1'), 'Lucilla Basic 1 (held)'); +}); 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_build_moves.py b/tools/ww_build_moves.py new file mode 100644 index 0000000..9a9d127 --- /dev/null +++ b/tools/ww_build_moves.py @@ -0,0 +1,298 @@ +""" +Draft static/data/ww_characters.json (the move data the app uses) from the raw encore.moe snapshot. + +ComboTracker only cares about key inputs, so each character keeps just: + - basic: how many hits the Basic Attack chain has (A1, A2, ...), each hit's name, + and whether holding LMB is a Heavy Attack or just keeps the chain going + - moves: attack and skill names with the input that casts them + - chain_entry: where LMB picks the chain back up after another move + ("LMB right after the Intro is Basic Attack Stage 3" -> {"Intro Skill": 3}) + - followups: other "press X shortly after Y to cast Z" links from the kit text +No damage numbers, scaling, cooldowns or buff text. + +Usage: + python tools/ww_build_moves.py # draft every character from data/encore_raw/ + python tools/ww_build_moves.py --force # also re-draft characters marked "reviewed": true + +Characters marked "reviewed": true were checked by hand and are kept as they are, so re-running +after a new raw download only fills in new characters. Get the raw snapshot with the Characters +page's "Download raw data" button (it saves to data/encore_raw/ and changes nothing else). +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +import ww_library # noqa: E402 + +RAW_DIR = ROOT / "data" / ww_library.RAW_DIR_NAME +OUT_PATH = ROOT / "static" / "data" / "ww_characters.json" + +# encore.moe skill type -> the input that casts it in tracker notation +SKILL_INPUTS = { + "Resonance Skill": "e", + "Resonance Liberation": "r", + "Intro Skill": "swap in", + "Outro Skill": "swap out", + "Tune Break": "f", +} +INPUT_WORDS = {"normal attack": "lmb", "resonance skill": "e", "resonance liberation": "r"} +# Move-name prefixes that are always player actions (vs. states/buffs such as "Majesty"). +ACTION_PREFIX = re.compile( + r"^(basic attack|heavy attack|mid-air|dodge counter|plunging|resonance skill|resonance liberation|" + r"intro skill|outro skill|tune break|[\w' -]+ - (basic attack|heavy attack|dodge counter|mid-air attack))", + re.I, +) + +_HEADING = re.compile(r"^\*\*([^*]+)\*\*\s*$") +_FOLLOWUP = re.compile( + r"(?:press|use|hold|tap)\s+(?:the\s+)?(?:\*\*)?(normal attack|resonance skill)(?:\*\*)?(?:\s+button)?(?:\s+again)?" + r"\s+(?:shortly\s+|right\s+|immediately\s+)?(?:within a certain period(?: of time)?\s+)?after\s+" + r"(?:casting|performing|using|triggering)\s+(?:resonance (?:skill|liberation)\s+|intro skill\s+)?" + r"(this (?:skill|attack)|\*\*([^*]+)\*\*)" + r"[^.]*?\bto\s+(?:cast|perform|trigger|chain into)\s+(?:resonance (?:skill|liberation)\s+|heavy attack\s+)?" + r"(?:\*\*([^*]+)\*\*|([A-Z][\w' :-]*?Stage \d+))", + re.I, +) +_STAGE = re.compile(r"\bStage\s*(\d+)\b", re.I) +_CHAIN = re.compile(r"up to (\d+) consecutive attacks", re.I) +_HOLD_CHAIN = re.compile(r"(?:or|and) hold(?: it down| down)?[^.]{0,40}?to perform up to \d+ consecutive", re.I) +_HOLD_SEQUENCE = re.compile(r"hold \*\*normal attack\*\* to cast \*\*basic attack stage", re.I) + + +def _headings(desc: str) -> list[str]: + out = [] + for line in (desc or "").splitlines(): + m = _HEADING.match(line.strip()) + if m: + name = m.group(1).strip() + if name and name not in out: + out.append(name) + return out + + +def _is_move(name: str, mult_names: list[str], desc: str) -> bool: + """A heading is a move (not a state or buff) if it deals damage, reads as an action, or is cast.""" + low = name.lower() + if ACTION_PREFIX.match(name): + return True + if any(m.lower().startswith(low) for m in mult_names): + return True + return bool(re.search(r"(?:cast|perform)s?\s+(?:resonance (?:skill|liberation)\s+)?\*\*" + re.escape(name) + r"\*\*", desc, re.I)) + + +def _basic_hit_names(base: str, mult_names: list[str], hits: int | None) -> list[str]: + """['Stage 1', 'Stage 2', 'Stage 3 - Unremarkable / Commendable', ...] from the multiplier table.""" + if not hits: + return [] + want = base.lower() + per: dict[int, list[str]] = {} + for m in mult_names: + low = m.lower() + if want not in ("basic attack",) and not low.startswith(want): + continue + if want == "basic attack" and not (low.startswith("basic attack") or low.startswith("stage ")): + continue + if want == "basic attack" and re.match(r"basic attack\s*-\s*\w", low): + continue # "Basic Attack - Tracing Forms ..." is another form's chain + st = _STAGE.search(m) or re.search(rf"{re.escape(base)}\s+(\d+)\b", m, re.I) + if not st: + continue + n = int(st.group(1)) + tail = m[st.end():].strip() + tail = re.sub(r"\s*(DMG|Hold DMG|STA Cost.*)$", "", tail, flags=re.I).strip(" -:") + per.setdefault(n, []) + if tail and tail not in per[n]: + per[n].append(tail) + out = [] + for n in range(1, hits + 1): + extra = per.get(n) or [] + out.append(f"Stage {n}" + (f" - {' / '.join(extra)}" if extra else "")) + return out + + +def _chain_key(after: str) -> str: + """ + The move a chain_entry follows, as the timeline sees it (static/ww_moves.js): + intro (1/2/3 swap-in), skill (e), liberation (r), tune_break (f), + heavy (hold lmb), dodge (rmb, lmb), midair (space, lmb). '' when it's something else. + """ + low = after.lower() + for prefix, key in (("intro skill", "intro"), ("resonance skill", "skill"), ("resonance liberation", "liberation"), + ("tune break", "tune_break")): + if low == prefix: + return key + if re.match(r"^(?:[\w' :]+ - )?heavy attack", low) or low.startswith("heavy attack"): + return "heavy" + if "dodge counter" in low: + return "dodge" + if "mid-air attack" in low or "plunging" in low: + return "midair" + return "" + + +def _sections(skill: dict[str, Any]) -> list[tuple[str, str]]: + """Split a skill's text at its bold headings: [(heading, text)]. Text before any heading uses the skill type.""" + text = (skill["description"] or "").replace("{Cus:Ipt,Touch=Tapping PC=Pressing Gamepad=Pressing}", "Press") + out: list[tuple[str, str]] = [] + title, buf = skill["type"], [] + for line in text.splitlines(): + m = _HEADING.match(line.strip()) + if m: + if buf: + out.append((title, "\n".join(buf))) + title, buf = m.group(1).strip(), [] + else: + buf.append(line) + if buf: + out.append((title, "\n".join(buf))) + return out + + +def _followups(skill: dict[str, Any], base_basic: str) -> list[dict[str, Any]]: + out = [] + for title, text in _sections(skill): + # "This skill" means the heading it sits under; the Intro/Skill/Liberation keep their type name. + this = skill["type"] if skill["type"] in SKILL_INPUTS or title == skill["name"] else title + for m in _FOLLOWUP.finditer(text): + after = this if m.group(2).lower().startswith("this ") else m.group(3).strip() + gives = (m.group(4) or m.group(5) or "").strip() + item: dict[str, Any] = {"after": after, "press": INPUT_WORDS[m.group(1).lower()], "gives": gives} + st = _STAGE.search(gives) + if st and re.match(re.escape(base_basic) + r"\s+Stage\s*\d", gives, re.I) and item["press"] == "lmb": + item["basic_stage"] = int(st.group(1)) + if item not in out: + out.append(item) + return out + + +def draft_character(raw: dict[str, Any], roster_row: dict[str, Any]) -> dict[str, Any]: + kit = ww_library.parse_kit(raw) + # Rover's kit lists some skills once per gender; keep one of each. + skills, seen = [], set() + for s in kit["skills"]: + if (s["type"], s["name"]) in seen: + continue + seen.add((s["type"], s["name"])) + skills.append(s) + normal = next((s for s in skills if s["type"] == "Normal Attack"), None) + + basic: dict[str, Any] = {"name": "Basic Attack", "hits": None, "hit_names": [], "hold_lmb": "heavy"} + moves: list[dict[str, Any]] = [] + if normal: + desc = normal["description"] + mult = [m["name"] for m in normal["multipliers"]] + heads = _headings(desc) + if heads and heads[0].lower().startswith(("basic attack", "moonring", "basic")) or (heads and "basic attack" in heads[0].lower()): + basic["name"] = heads[0] + m = _CHAIN.search(desc) + if m: + basic["hits"] = int(m.group(1)) + basic["hit_names"] = _basic_hit_names(basic["name"], mult, basic["hits"]) + if _HOLD_CHAIN.search(desc) or _HOLD_SEQUENCE.search(desc): + basic["hold_lmb"] = "chain" + # Short name the timeline uses for hold(lmb), e.g. "Heavy" -> "Zani Heavy". + basic["hold_label"] = "Heavy" if basic["hold_lmb"] == "heavy" else "" + for h in heads: + if not _is_move(h, mult, desc): + continue + low = h.lower() + if low == basic["name"].lower(): + inp = "lmb" + elif "heavy attack" in low: + inp = "hold(lmb)" + elif "mid-air" in low or "plunging" in low: + inp = "space, lmb" + elif "dodge counter" in low: + inp = "rmb, lmb" + else: + inp = "lmb" + moves.append({"input": inp, "name": h}) + + chain_entry: dict[str, int] = {} + followups: list[dict[str, Any]] = [] + for s in skills: + if s["type"] in SKILL_INPUTS: + moves.append({"input": SKILL_INPUTS[s["type"]], "name": s["name"], "type": s["type"]}) + if s["type"] in ("Normal Attack", "Resonance Skill", "Forte Circuit", "Resonance Liberation", "Intro Skill"): + mult = [m["name"] for m in s["multipliers"]] + for h in _headings(s["description"]): + if h == s["name"] or s["type"] == "Normal Attack": + continue + if _is_move(h, mult, s["description"]): + moves.append({"input": "", "name": h, "type": s["type"]}) + for f in _followups(s, basic["name"]): + if f in followups: + continue + followups.append(f) + when = _chain_key(f["after"]) + if "basic_stage" in f and when and when not in chain_entry: + chain_entry[when] = f["basic_stage"] + + return { + "id": kit["id"], + "name": kit["name"], + "element": kit["element"] or roster_row.get("element", ""), + "weapon": roster_row.get("weapon", ""), + "rarity": roster_row.get("rarity"), + "icon": kit["icon"] or roster_row.get("icon", ""), + "reviewed": False, + "basic": basic, + "moves": moves, + "chain_entry": chain_entry, + "followups": followups, + "notes": "", + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--force", action="store_true", help="re-draft characters marked reviewed too") + args = ap.parse_args() + + if not (RAW_DIR / "roster.json").exists(): + print(f"No raw data in {RAW_DIR}. Download it first (see this file's docstring).") + return 1 + roster = ww_library.parse_roster(json.loads((RAW_DIR / "roster.json").read_text(encoding="utf-8"))) + existing: dict[str, Any] = {} + if OUT_PATH.exists(): + existing = json.loads(OUT_PATH.read_text(encoding="utf-8")).get("characters", {}) + + chars: dict[str, Any] = {} + kept = drafted = 0 + for row in roster["characters"]: + key = row["name"].lower() + old = existing.get(key) + if old and old.get("reviewed") and not args.force: + chars[key] = old + kept += 1 + continue + raw_path = RAW_DIR / f"kit_{row['id']}.json" + if not raw_path.exists(): + if old: + chars[key] = old + continue + chars[key] = draft_character(json.loads(raw_path.read_text(encoding="utf-8")), row) + drafted += 1 + + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + doc = { + "about": "Key-input move data for ComboTracker, drafted from encore.moe by tools/ww_build_moves.py. " + "Entries with \"reviewed\": true were checked by hand; edit this file directly to fix or add details.", + "characters": dict(sorted(chars.items())), + } + OUT_PATH.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"{OUT_PATH.relative_to(ROOT)}: {drafted} drafted, {kept} reviewed kept as-is") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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..c060628 100644 --- a/ui_server.py +++ b/ui_server.py @@ -12,13 +12,16 @@ 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 +import input_normalization 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 +66,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 +119,65 @@ 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() + self._send_json(*ww_library.handle_api(library, self.path)) + + def do_POST(self) -> None: + # Characters page "Download raw data": saves encore.moe's unedited JSON next to the app. + # It only writes data/encore_raw/; the page keeps showing static/data/ww_characters.json. + if self.path.rstrip("/") != ww_library.API_PREFIX + "scrape-raw": + return self._send_json(404, {"error": "Unknown API route"}) + origin = self.headers.get("Origin") + if origin and origin not in ALLOWED_WS_ORIGINS: # other websites can't trigger downloads + return self._send_json(403, {"error": "Not allowed from this page"}) + try: + result = ww_library.scrape_raw(engine.data_dir / "data" / ww_library.RAW_DIR_NAME) + except (ww_library.LibraryError, OSError, ValueError) as e: + return self._send_json(502, {"error": str(e)}) + self._send_json(200, result) + + def _send_json(self, status: int, payload: dict[str, Any]) -> None: + 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 +214,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 +235,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 +265,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 +276,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 +435,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 @@ -366,7 +445,8 @@ 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 # a synthetic Esc/F9 from stopping its own macro while physical stop keys @@ -419,7 +499,8 @@ 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 if transcribe_mode_enabled: @@ -435,7 +516,8 @@ 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 if transcribe_mode_enabled: @@ -555,8 +637,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..26789af --- /dev/null +++ b/ww_library.py @@ -0,0 +1,406 @@ +""" +Wuthering Waves character library: every character's moves and the community's team rotations. + +Sources: + - encore.moe JSON API: character list and each character's full kit (skill text, multipliers, icons). + Downloaded only on request, as a raw snapshot (scrape_raw); the app shows the hand-checked + static/data/ww_characters.json that tools/ww_build_moves.py drafts from it. + 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, fetched on demand and cached on disk): one block per main character listing team setups with author, + video, and a link to a Google Doc tab holding the move-by-move rotation transcript (damage columns are ignored). + +Rotations and transcripts are 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 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 + ) + + +# --------------------------------------------------------------------------- +# Raw snapshot (the Characters page's "Download raw data" button and tools/ww_build_moves.py) +# --------------------------------------------------------------------------- + +RAW_DIR_NAME = "encore_raw" + + +def scrape_raw(dest_dir: Path, fetch=None) -> dict[str, Any]: + """ + Save encore.moe's unedited JSON (character list + every kit) under dest_dir. + This is source material only: the app reads the hand-checked static/data/ww_characters.json, + which tools/ww_build_moves.py drafts from these files. + """ + fetch = fetch or _http_get + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + roster_text = fetch(ENCORE_API) + roster = json.loads(roster_text) + (dest / "roster.json").write_text(json.dumps(roster, ensure_ascii=False, indent=1), encoding="utf-8") + saved, failed = 0, [] + seen: set[int] = set() + for r in roster.get("roleList") or []: + cid = r.get("Id") + if not isinstance(cid, int) or cid in seen: + continue + seen.add(cid) + try: + kit = json.loads(fetch(f"{ENCORE_API}/{cid}")) + except Exception as e: # keep going; report which ones failed + failed.append(f"{r.get('Name') or cid}: {e}") + continue + (dest / f"kit_{cid}.json").write_text(json.dumps(kit, ensure_ascii=False, indent=1), encoding="utf-8") + saved += 1 + (dest / "README.txt").write_text( + "Raw encore.moe data, saved as-is on " + + time.strftime("%Y-%m-%d %H:%M") + + ".\nNot read by the app. Run tools/ww_build_moves.py to draft static/data/ww_characters.json from it.\n", + encoding="utf-8", + ) + return {"saved": saved, "failed": failed, "folder": str(dest)} + + +# --------------------------------------------------------------------------- +# 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 _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(), "teams": []} + groups.append(group) + 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), "") + # Team rows are the ones with a number in the DPS column; the number itself isn't kept + # (ComboTracker is about which keys to press, not damage). + if not team or _num(col("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"), + "video": col("video showcase"), + "transcript": col("rotation transcript"), + }) + 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. + 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 == "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")