diff --git a/CHANGELOG.md b/CHANGELOG.md index d8128a0..c516559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,3 +27,24 @@ - T15: Teams + Lutris launcher. `src/team.rs` (Team, list_teams, teams_dir, current_team), `src/lutris.rs` (LutrisGame parser, `load_all` with bad-YAML tolerance), `src/launcher.rs` (SpawnPlan merging Lutris config with per-character wine-prefix override). GUI: Teams menu (New / Switch / Refresh / Show / Delete) and Launch menu (per-character + Launch all); Lutris picker visible only in the New-team flow. Direct Wine spawn — never calls `lutris` CLI - `game_binds.interact` example uses **Alt+J** (Master's request). Interact with Target is a built-in WoW keybind; enBoxer just routes the keystroke. - `docs/MACROS.md` rewritten for current retail (The War Within era, 2026): rename "CTM" → Auto-Interact (the CVar), document Alt+J for Interact with Target, document per-character Assist / Follow macro override, add optional Pet Attack / Target Last Target keybinds. + +## Unreleased — Interact chain (ISBoxer-style mapped key) + +- **Smart interact shortcut.** A step with `bind: interact` now expands at + compile time into the full CTM-on → Interact-with-Target → sleep + `walk_delay_ms` → CTM-off chain, driven by `profile.interact` (`style` + + `walk_delay_ms`). One user keypress, four keystrokes dispatched to + every captured slot. Styles: + - `standard` (default): chain runs, CTM ends off. + - `auto`: chain runs, CTM stays on (toggle via `ctm_off` later). + - `hold`: press fires CTM-on + Alt+J; put `bind: ctm_off` in + `release_steps` to fire it on hotkey release. +- The example `loot` map (Alt+G) and `interact` map (Alt+I) now use the + shortcut. A `loot_manual` map (Ctrl+Alt+G) exercises the explicit + chain for users who want per-step control. +- New tests: `interact_smart_shortcut_standard_emits_full_sequence`, + `interact_smart_shortcut_auto_emits_two_no_tail`, + `interact_smart_shortcut_hold_emits_press_only`. 92/92 unit tests pass; + clippy clean. +- Docs: `docs/MACROS.md` "Smart interact shortcut" section, `docs/NOTES.md` + callout, `examples/profile.yaml` updated with comments. diff --git a/docs/MACROS.md b/docs/MACROS.md index f93761d..94d4f36 100644 --- a/docs/MACROS.md +++ b/docs/MACROS.md @@ -77,3 +77,45 @@ Both are pure in-game keybinds; enBoxer just routes the keystroke. Do not mash. A second interact while they are still pathing is how characters run circles. If they stop short, raise `walk_delay_ms` (2500–4000), or use `interact.style: auto` (CTM stays on), or `interact.style: hold`. + +## Smart interact shortcut (`bind: interact`) + +The example profile's `loot` map (Alt+G) and `interact` map (Alt+I) both use a **smart shortcut**: a single user keypress fires the full chain (CTM on → Interact with Target → sleep `walk_delay_ms` → CTM off). It is what ISBoxer did with a "Mapped Key" — you press one key, the multibox software sends the whole chain. + +In enBoxer the shortcut is `bind: interact` in a step: + +```yaml +- name: loot + hotkey: "Alt+G" + steps: + - bind: assist + target: others + - bind: interact # <-- smart shortcut + target: others +``` + +The engine expands that one step at compile time. Driven by `profile.interact`: + +| `interact.style` | What happens on the press | CTM after the press | +| ---------------- | ----------------------------------------------------------------------------------------- | --------------------- | +| `standard` | `ctm_on` → `interact` (`game_binds.interact`) → sleep `walk_delay_ms` → `ctm_off` | off | +| `auto` | `ctm_on` → `interact` | stays on (toggle off via `ctm_off` when you want) | +| `hold` | `ctm_on` → `interact` (Down); put `bind: ctm_off` in `release_steps` to turn it off on release | matches the hotkey down/up | + +Walk distance and follower latency are absorbed by `walk_delay_ms` (default 2500 ms). If your alts stop short, raise it (2500–5000 ms is typical for indoor encounters). + +If you need per-step control (different keys, branches, conditional delays), use the manual form: + +```yaml +- name: loot_manual + hotkey: "Ctrl+Alt+G" + steps: + - bind: assist + - bind: ctm_on + - bind: interact + - delay_ms: 5000 + - bind: ctm_off +``` + +Both forms are tested in `engine::tests`; see `interact_smart_shortcut_standard_emits_full_sequence`, `…_auto_emits_two_no_tail`, `…_hold_emits_press_only`, and `loot_manual` (which exercises the explicit chain). + diff --git a/docs/NOTES.md b/docs/NOTES.md index baeb05b..2fe2f97 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -37,3 +37,11 @@ If a window is **native Wayland** (including `winewayland`), unfocused inject of The Session page lists each window as `XWayland/Wine` or `Wayland`. Visible-pixel capture: `grim` (default). Overlay: `mpv --wayland-app-id=enboxer-vfx` plus JSON IPC to reload frames. Covered sources use `zwlr_export_dmabuf_unstable_v1` (Hyprland implements the wlroots protocol); gated behind `ENBOXER_ENABLE_TOPLEVEL=1`. See [docs/VIDEO.md](VIDEO.md). + +## Smart interact shortcut + +`bind: interact` in any map step expands at compile time into the full chain +(CTM on → Interact with Target → walk delay → CTM off), driven by +`profile.interact` (`style` + `walk_delay_ms`). This is the ISBoxer Mapped +Key analog — one user keypress, four keystrokes dispatched to every captured +slot. See `docs/MACROS.md` for the per-style table and the manual form. diff --git a/examples/profile.yaml b/examples/profile.yaml index 7fa2f7a..4279f9d 100644 --- a/examples/profile.yaml +++ b/examples/profile.yaml @@ -106,7 +106,22 @@ maps: target: others - name: loot + # ISBoxer Mapped Key analog: one user keypress fires the entire interact + # chain (CTM on -> Interact with Target -> wait walk_delay_ms -> CTM off), + # driven by profile.interact (style + walk_delay_ms). The follower has + # walk_delay_ms to actually walk to the target before CTM turns off. hotkey: "Alt+G" + steps: + - bind: assist + target: others + - bind: interact + target: others + - name: loot_manual + # Same idea as `loot` but composed by hand. Pick this form if you want to + # do anything fancy between the CTM toggle and the interact (e.g. a delay + # specific to a slow class, or a different key for each step). + hotkey: "Ctrl+Alt+G" + hold: false steps: - bind: assist target: others @@ -114,10 +129,11 @@ maps: target: others - bind: interact target: others - - delay_ms: 2500 + - delay_ms: 5000 - bind: ctm_off target: others + - name: interact_hold hotkey: "Alt+H" hold: true diff --git a/src/engine.rs b/src/engine.rs index a332548..8a8a213 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -205,6 +205,72 @@ impl Engine { out.push(Action::Sleep(Duration::from_millis(ms))); continue; } + // ISBoxer-style Mapped Key shortcut: `bind: "interact"` fires the + // full CTM-toggle + Interact-with-Target + (optional) wait + CTM-off + // chain driven by profile.interact (style + walk_delay_ms). + // The user only pressed the map hotkey; the daemon composes the chain. + if step.bind.as_deref() == Some("interact") { + let slots = self.resolve_targets(&step.target, &map_name)?; + let interact_key = self + .profile + .game_binds + .get("interact") + .cloned() + .unwrap_or_default(); + let ctm_on = self + .profile + .game_binds + .get("ctm_on") + .cloned() + .unwrap_or_default(); + let ctm_off = self + .profile + .game_binds + .get("ctm_off") + .cloned() + .unwrap_or_default(); + + // 1. CTM on (sends the macro key that toggles autoInteract on). + if !ctm_on.is_empty() { + out.push(Action::Send { + key: ctm_on, + slots: slots.clone(), + hold, + }); + } + // 2. Interact with Target keybind. + if !interact_key.is_empty() { + out.push(Action::Send { + key: interact_key, + slots: slots.clone(), + hold, + }); + } + // 3. Style-driven tail. + match self.profile.interact.style { + crate::profile::InteractStyle::Standard => { + out.push(Action::Sleep(Duration::from_millis( + self.profile.interact.walk_delay_ms, + ))); + if !ctm_off.is_empty() { + out.push(Action::Send { + key: ctm_off, + slots, + hold, + }); + } + } + crate::profile::InteractStyle::Auto => { + // CTM stays on after the press. The user toggles it off + // (via game_binds.ctm_off) when they explicitly want to. + } + crate::profile::InteractStyle::Hold => { + // CTM is bound on the press; ctm_off goes on release_steps. + // No tail here. + } + } + continue; + } let key = if let Some(k) = &step.key { k.clone() } else if let Some(b) = &step.bind { @@ -273,9 +339,34 @@ mod tests { release_steps: vec![], }, Map { + // Smart shortcut: one user keypress fires the entire interact + // chain (CTM on -> Alt+J -> sleep walk_delay_ms -> CTM off). name: "loot".into(), hotkey: Hotkey("Alt+G".into()), hold: false, + steps: vec![ + Step { + bind: Some("assist".into()), + key: None, + delay_ms: None, + target: "others".into(), + }, + Step { + bind: Some("interact".into()), + key: None, + delay_ms: None, + target: "others".into(), + }, + ], + release_steps: vec![], + }, + // Manual form: same chain composed by hand. Use this when you + // need per-step control (different key per step, custom delay + // between, conditional branches, etc.). + Map { + name: "loot_manual".into(), + hotkey: Hotkey("Ctrl+Alt+G".into()), + hold: false, steps: vec![ Step { bind: Some("assist".into()), @@ -296,7 +387,7 @@ mod tests { target: "others".into(), }, Step { - delay_ms: Some(2500), + delay_ms: Some(5000), key: None, bind: None, target: "others".into(), @@ -414,6 +505,119 @@ mod tests { } } + #[test] + fn interact_smart_shortcut_standard_emits_full_sequence() { + // bind: "interact" should fire CTM-on, Interact with Target, + // sleep walk_delay_ms, CTM-off (ISBoxer Mapped Key analog). + let mut e = sample(); + e.profile.interact.style = crate::profile::InteractStyle::Standard; + e.profile.interact.walk_delay_ms = 2500; + let map = crate::profile::Map { + name: "interact_short".into(), + hotkey: Hotkey("Alt+I".into()), + hold: false, + steps: vec![crate::profile::Step { + key: None, + bind: Some("interact".into()), + delay_ms: None, + target: "others".into(), + }], + release_steps: vec![], + }; + e.profile.maps.push(map); + let acts = e.fire("Alt+I", Hold::Tap).unwrap(); + match &acts[..] { + [Action::Send { key: k1, slots: s1, .. }, + Action::Send { key: k2, slots: s2, .. }, + Action::Sleep(d), + Action::Send { key: k3, slots: s3, .. }] => { + assert_eq!(k1, "Shift+F3"); + assert_eq!(k2, "g"); + assert_eq!(*d, Duration::from_millis(2500)); + assert_eq!(k3, "Shift+F4"); + assert_eq!(s1, &vec![2, 3]); + assert_eq!(s2, &vec![2, 3]); + assert_eq!(s3, &vec![2, 3]); + } + other => panic!("expected [ctm_on, interact, sleep, ctm_off], got {other:?}"), + } + } + + #[test] + fn interact_smart_shortcut_auto_emits_two_no_tail() { + // style = auto: CTM stays on. No sleep, no ctm_off in the press. + let mut e = sample(); + e.profile.interact.style = crate::profile::InteractStyle::Auto; + e.profile.interact.walk_delay_ms = 9999; + let map = crate::profile::Map { + name: "interact_auto".into(), + hotkey: Hotkey("Alt+I".into()), + hold: false, + steps: vec![crate::profile::Step { + key: None, + bind: Some("interact".into()), + delay_ms: None, + target: "others".into(), + }], + release_steps: vec![], + }; + e.profile.maps.push(map); + let acts = e.fire("Alt+I", Hold::Tap).unwrap(); + match &acts[..] { + [Action::Send { key: k1, .. }, Action::Send { key: k2, .. }] => { + assert_eq!(k1, "Shift+F3"); + assert_eq!(k2, "g"); + } + other => panic!("expected [ctm_on, interact] only, got {other:?}"), + } + } + + #[test] + fn interact_smart_shortcut_hold_emits_press_only() { + // style = hold: press fires CTM-on + IWT (Down); ctm_off must be in + // release_steps explicitly. The smart shortcut does not emit a tail. + let mut e = sample(); + e.profile.interact.style = crate::profile::InteractStyle::Hold; + e.profile.interact.walk_delay_ms = 5000; + let map = crate::profile::Map { + name: "interact_hold".into(), + hotkey: Hotkey("Alt+I".into()), + hold: true, + steps: vec![crate::profile::Step { + key: None, + bind: Some("interact".into()), + delay_ms: None, + target: "others".into(), + }], + release_steps: vec![crate::profile::Step { + key: None, + bind: Some("ctm_off".into()), + delay_ms: None, + target: "others".into(), + }], + }; + e.profile.maps.push(map); + let press = e.fire("Alt+I", Hold::Down).unwrap(); + match &press[..] { + [Action::Send { key: k1, hold: h1, .. }, + Action::Send { key: k2, hold: h2, .. }] => { + assert_eq!(k1, "Shift+F3"); + assert_eq!(k2, "g"); + assert_eq!(*h1, Hold::Down); + assert_eq!(*h2, Hold::Down); + } + other => panic!("press not 2 actions, got {other:?}"), + } + let release = e.fire("Alt+I", Hold::Up).unwrap(); + match &release[..] { + [Action::Send { key: k, hold: h, .. }] => { + assert_eq!(k, "Shift+F4"); + assert_eq!(*h, Hold::Up); + } + other => panic!("release not 1 action, got {other:?}"), + } + } + #[test] fn disabled_mode_intercepts_nothing() { let mut e = sample();