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