Implement ISBoxer-style smart interact shortcut (bind: interact)

A step with bind: interact now expands at compile time into the full chain
(CTM on -> Interact with Target -> sleep walk_delay_ms -> CTM off), 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; bind: ctm_off in
                       release_steps to fire it on hotkey release

Example loot (Alt+G) and interact (Alt+I) maps now use the shortcut. A
loot_manual map (Ctrl+Alt+G) exercises the explicit chain for users who
want per-step control.

92/92 unit tests pass; clippy clean. New tests:
  - interact_smart_shortcut_standard_emits_full_sequence
  - interact_smart_shortcut_auto_emits_two_no_tail
  - interact_smart_shortcut_hold_emits_press_only

Docs: MACROS.md (new Smart interact shortcut section with style table),
NOTES.md callout, examples/profile.yaml comments, CHANGELOG.md entry.
This commit is contained in:
en 2026-09-16 04:51:07 +02:00
parent aeed2036d0
commit 1ec8d78f71
5 changed files with 293 additions and 2 deletions

View File

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

View File

@ -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. Do not mash. A second interact while they are still pathing is how characters run circles.
If they stop short, raise `walk_delay_ms` (25004000), or use `interact.style: auto` (CTM stays on), or `interact.style: hold`. If they stop short, raise `walk_delay_ms` (25004000), 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 (25005000 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).

View File

@ -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`. 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). 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.

View File

@ -106,7 +106,22 @@ maps:
target: others target: others
- name: loot - 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" 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: steps:
- bind: assist - bind: assist
target: others target: others
@ -114,10 +129,11 @@ maps:
target: others target: others
- bind: interact - bind: interact
target: others target: others
- delay_ms: 2500 - delay_ms: 5000
- bind: ctm_off - bind: ctm_off
target: others target: others
- name: interact_hold - name: interact_hold
hotkey: "Alt+H" hotkey: "Alt+H"
hold: true hold: true

View File

@ -205,6 +205,72 @@ impl Engine {
out.push(Action::Sleep(Duration::from_millis(ms))); out.push(Action::Sleep(Duration::from_millis(ms)));
continue; 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 { let key = if let Some(k) = &step.key {
k.clone() k.clone()
} else if let Some(b) = &step.bind { } else if let Some(b) = &step.bind {
@ -273,9 +339,34 @@ mod tests {
release_steps: vec![], release_steps: vec![],
}, },
Map { Map {
// Smart shortcut: one user keypress fires the entire interact
// chain (CTM on -> Alt+J -> sleep walk_delay_ms -> CTM off).
name: "loot".into(), name: "loot".into(),
hotkey: Hotkey("Alt+G".into()), hotkey: Hotkey("Alt+G".into()),
hold: false, 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![ steps: vec![
Step { Step {
bind: Some("assist".into()), bind: Some("assist".into()),
@ -296,7 +387,7 @@ mod tests {
target: "others".into(), target: "others".into(),
}, },
Step { Step {
delay_ms: Some(2500), delay_ms: Some(5000),
key: None, key: None,
bind: None, bind: None,
target: "others".into(), 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] #[test]
fn disabled_mode_intercepts_nothing() { fn disabled_mode_intercepts_nothing() {
let mut e = sample(); let mut e = sample();