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

-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.

@@ -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 = '— Select Combo — ';
- 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 = '— New Team — ';
- 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 = '— (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 => `${escapeHtml(c.name)} `).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 = '— Select Combo — ';
- (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 = '— No team — ';
+ 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 = '— (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 => `${escapeHtml(c.name)} `).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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 `${dot}${label} `;
+ });
+ $('elementFilter').innerHTML = chips.join('');
+}
+
+function rosterCard(c) {
+ const sel = c.id === state.selectedId ? ' selected' : '';
+ const icon = safeUrl(c.icon);
+ return `
+ ${icon ? ` ` : ' '}
+ ${esc(c.name)}
+ `;
+}
+
+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)}
+
+
+
+ Moves
+ Team rotations ${count}
+
`;
+}
+
+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 ? `` : ''}
+
+ Moves
+
+ ${otherHtml ? `
Other named moves ${otherHtml}` : ''}
+
+ ${follow ? `` : ''}`;
+}
+
+// --- 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 ? `
Combo ` : ''}
+ ${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}
` : ''}
+ `;
+}
+
+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
+
+
-
+
-