use crate::hotkey::passthrough_id; use crate::profile::{Map, Mode, Profile, Step}; use anyhow::{bail, Result}; use std::collections::{HashMap, HashSet}; use std::time::Duration; #[derive(Debug, Clone)] pub struct Engine { pub profile: Profile, pub mode: Mode, pub leader_slot: u32, passthrough: HashSet, /// Per-map cursor for `target: round_robin` / `rr`. The map name is /// the key; the value is the next slot index to fire (1..=slots). /// Reset on a fresh Engine; not persisted across reload. round_robin: HashMap, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { Send { key: String, slots: Vec, hold: Hold, }, Sleep(Duration), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Hold { Tap, Down, Up, } impl Engine { pub fn new(profile: Profile) -> Result { let mode = profile.mode_default; let passthrough = profile.passthrough_set()?; Ok(Self { leader_slot: 1, mode, passthrough, profile, round_robin: HashMap::new(), }) } pub fn set_leader(&mut self, slot: u32) { if slot >= 1 && slot <= self.profile.slots { self.leader_slot = slot; } } pub fn cycle_mode(&mut self) -> Mode { self.mode = self.mode.cycle(); self.mode } pub fn should_intercept(&self, hotkey: &str) -> bool { if self.mode == Mode::Off { return false; } if let Ok(id) = passthrough_id(hotkey) { if self.passthrough.contains(&id) { return false; } } self.profile.map_by_hotkey(hotkey).is_some() || (self.mode == Mode::Mirror && self.is_mirror_key(hotkey)) } fn is_mirror_key(&self, hotkey: &str) -> bool { if self.profile.repeater.keys.is_empty() { return true; } let want = passthrough_id(hotkey).unwrap_or_else(|_| hotkey.to_string()); self.profile.repeater.keys.iter().any(|k| { k.eq_ignore_ascii_case(hotkey) || passthrough_id(k).ok().is_some_and(|id| id == want) }) } /// Resolve a `target:` field on a step into the slot ids that should /// receive the action. Side effect: when `target` is `round_robin` / /// `rr`, advances the per-map cursor for `map_name` (if provided). /// The cursor lives on the engine so two `Engine` instances each /// carry their own round-robin state — two profiles do not share it. pub fn resolve_targets(&mut self, target: &str, map_name: &str) -> Result> { let all: Vec = (1..=self.profile.slots).collect(); let leader = self.leader_slot; match target { "current" => Ok(vec![leader]), "others" | "all_except_current" => { Ok(all.into_iter().filter(|s| *s != leader).collect()) } "all" => Ok(all), "round_robin" | "rr" => { if self.profile.slots == 0 { bail!("round_robin needs at least 1 slot"); } let n = self.profile.slots as usize; let current = self .round_robin .entry(map_name.to_string()) .or_insert(1); let pick = *current; *current = (pick % n) + 1; Ok(vec![pick as u32]) } other if other.starts_with("group:") => { let name = &other[6..]; let g = self .profile .groups .get(name) .ok_or_else(|| anyhow::anyhow!("unknown group {name}"))?; Ok(g.slots.clone()) } other if other.starts_with("slots:") => { let mut out = Vec::new(); for p in other[6..].split(',') { let n: u32 = p.trim().parse()?; out.push(n); } Ok(out) } other if other.starts_with("slot:") => Ok(vec![other[5..].parse()?]), other => bail!("bad target {other:?}"), } } pub fn fire(&mut self, hotkey: &str, edge: Hold) -> Result> { if !self.should_intercept(hotkey) { return Ok(vec![]); } if let Some(map) = self.profile.map_by_hotkey(hotkey).cloned() { let edge = if map.hold && matches!(edge, Hold::Tap) { Hold::Down } else { edge }; return self.fire_map(&map, edge); } if self.mode == Mode::Mirror && matches!(edge, Hold::Tap | Hold::Down) { let slots = self.resolve_targets("others", "")?; return Ok(vec![Action::Send { key: hotkey.to_string(), slots, hold: if matches!(edge, Hold::Down) { Hold::Down } else { Hold::Tap }, }]); } Ok(vec![]) } fn fire_map(&mut self, map: &Map, edge: Hold) -> Result> { let steps = match edge { Hold::Up => &map.release_steps, _ => &map.steps, }; let hold = if map.hold && matches!(edge, Hold::Down) { Hold::Down } else if map.hold && matches!(edge, Hold::Up) { Hold::Up } else { Hold::Tap }; self.compile_steps(map, steps, hold) } fn resolve_party_bind(&self, name: &str) -> Result { if name == "assist" || name == "follow" { if let Some(ch) = self .profile .characters .iter() .find(|c| c.slot == self.leader_slot) { let k = if name == "assist" { &ch.assist_key } else { &ch.follow_key }; if !k.is_empty() { return Ok(k.clone()); } } } self.profile.resolve_bind(name) } fn compile_steps( &mut self, map: &Map, steps: &[Step], hold: Hold, ) -> Result> { let map_name = map.name.clone(); let mut out = Vec::new(); for step in steps { if let Some(ms) = step.delay_ms { out.push(Action::Sleep(Duration::from_millis(ms))); continue; } // ISBoxer-style Mapped Key shortcut: `bind: "smart_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. // For per-step control, use `bind: "interact"` to send a single // Interact-with-Target keystroke (game_binds.interact = Alt+J) and // compose ctm_on / ctm_off / walk_delay_ms yourself. if step.bind.as_deref() == Some("smart_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 { self.resolve_party_bind(b)? } else { continue; }; let slots = self.resolve_targets(&step.target, &map_name)?; out.push(Action::Send { key, slots, hold }); } Ok(out) } } #[cfg(test)] mod tests { use super::*; use crate::hotkey::Hotkey; use crate::profile::{Group, Interact, InteractStyle, Map, Profile, Step}; use pretty_assertions::assert_eq; use std::collections::BTreeMap; fn sample() -> Engine { let mut game_binds = BTreeMap::new(); game_binds.insert("interact".into(), "g".into()); game_binds.insert("ctm_on".into(), "Shift+F3".into()); game_binds.insert("ctm_off".into(), "Shift+F4".into()); game_binds.insert("assist".into(), "Shift+F2".into()); let mut groups = BTreeMap::new(); groups.insert("alts".into(), Group { slots: vec![2, 3] }); let profile = Profile { name: "t".into(), client: "wow-retail".into(), slots: 3, window_match: Default::default(), passthrough: vec!["e".into(), "s".into(), "d".into(), "f".into()], mode_default: Mode::Maps, repeater: Default::default(), game_binds, interact: Interact { style: InteractStyle::Standard, walk_delay_ms: 2500, }, session_hotkeys: BTreeMap::new(), characters: vec![], groups, maps: vec![ Map { name: "bar1".into(), hotkey: Hotkey("1".into()), hold: false, steps: vec![ Step { key: None, bind: Some("assist".into()), delay_ms: None, target: "others".into(), }, Step { key: Some("1".into()), bind: None, delay_ms: None, target: "all".into(), }, ], 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("smart_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()), key: None, delay_ms: None, target: "others".into(), }, Step { bind: Some("ctm_on".into()), key: None, delay_ms: None, target: "others".into(), }, Step { bind: Some("interact".into()), key: None, delay_ms: None, target: "others".into(), }, Step { delay_ms: Some(5000), key: None, bind: None, target: "others".into(), }, Step { bind: Some("ctm_off".into()), key: None, delay_ms: None, target: "others".into(), }, ], release_steps: vec![], }, ], video_fx: vec![], layout: Default::default(), }; Engine::new(profile).unwrap() } #[test] fn example_profile_loads() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/profile.yaml"); crate::profile::Profile::load(&path).unwrap(); } #[test] fn esdf_is_not_intercepted() { let e = sample(); assert!(!e.should_intercept("e")); assert!(!e.should_intercept("s")); assert!(e.should_intercept("1")); assert!(e.should_intercept("Alt+G")); } #[test] fn empty_passthrough_allows_mapping_esdf() { let mut e = sample(); e.passthrough.clear(); e.profile.passthrough.clear(); // still no map for e, so not intercepted assert!(!e.should_intercept("e")); } #[test] fn empty_passthrough_can_map_e() { let profile = Profile { name: "t".into(), client: "wow-retail".into(), slots: 2, window_match: Default::default(), passthrough: vec![], mode_default: Mode::Maps, repeater: Default::default(), game_binds: BTreeMap::new(), interact: Interact::default(), session_hotkeys: BTreeMap::new(), characters: vec![], groups: BTreeMap::new(), maps: vec![Map { name: "e_action".into(), hotkey: Hotkey("e".into()), hold: false, steps: vec![Step { key: Some("e".into()), bind: None, delay_ms: None, target: "current".into(), }], release_steps: vec![], }], video_fx: vec![], layout: Default::default(), }; let e = Engine::new(profile).unwrap(); assert!(e.should_intercept("e")); } #[test] fn others_skips_leader() { let mut e = sample(); e.set_leader(2); assert_eq!(e.resolve_targets("others", "").unwrap(), vec![1, 3]); assert_eq!(e.resolve_targets("current", "").unwrap(), vec![2]); assert_eq!(e.resolve_targets("group:alts", "").unwrap(), vec![2, 3]); } #[test] fn loot_is_single_iwt_with_delay() { let mut e = sample(); let actions = e.fire("Alt+G", Hold::Tap).unwrap(); match &actions[..] { [Action::Send { key: a, slots: s1, .. }, Action::Send { key: b, slots: s2, .. }, Action::Send { key: c, slots: s3, .. }, Action::Sleep(d), Action::Send { key: ekey, slots: s4, .. }] => { assert_eq!(a, "Shift+F2"); assert_eq!(b, "Shift+F3"); assert_eq!(c, "g"); assert_eq!(*d, Duration::from_millis(2500)); assert_eq!(ekey, "Shift+F4"); assert_eq!(s1, &vec![2, 3]); assert_eq!(s2, &vec![2, 3]); assert_eq!(s3, &vec![2, 3]); assert_eq!(s4, &vec![2, 3]); } other => panic!("unexpected {other:?}"), } } #[test] fn interact_simple_sends_only_alt_j() { // After the smart_interact split, `bind: "interact"` is a single // Alt+J send (game_binds.interact = "g"). No CTM toggle, no sleep, // no second send. Use this from manual chains or release_steps. let mut e = sample(); let map = crate::profile::Map { name: "interact_simple".into(), hotkey: Hotkey("Alt+U".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+U", Hold::Tap).unwrap(); assert_eq!(acts.len(), 1, "exactly one action: {acts:?}"); match &acts[0] { Action::Send { key, slots, hold } => { assert_eq!(key, "g"); assert_eq!(*hold, Hold::Tap); assert_eq!(slots, &vec![2, 3]); } other => panic!("expected Send, got {other:?}"), } } #[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("smart_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("smart_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("smart_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(); e.mode = Mode::Off; assert!(!e.should_intercept("1")); assert!(e.fire("1", Hold::Tap).unwrap().is_empty()); } #[test] fn assist_follows_leader_character() { let mut e = sample(); e.profile.characters = vec![ crate::profile::Character { slot: 1, name: "Main".into(), match_title: None, assist_key: "Shift+F2".into(), follow_key: "Shift+F1".into(), lutris_game: None, wine_prefix: None, auto_apply: false, }, crate::profile::Character { slot: 2, name: "Alt".into(), match_title: None, assist_key: "F9".into(), follow_key: "F10".into(), lutris_game: None, wine_prefix: None, auto_apply: false, }, ]; e.set_leader(2); let acts = e.fire("1", Hold::Tap).unwrap(); match &acts[0] { Action::Send { key, .. } => assert_eq!(key, "F9"), _ => panic!("expected send"), } } #[test] fn three_modes() { let mut e = sample(); e.mode = Mode::Maps; assert!(e.should_intercept("1")); assert!(!e.should_intercept("q")); e.mode = Mode::Mirror; assert!(e.should_intercept("q")); let acts = e.fire("q", Hold::Tap).unwrap(); assert_eq!( acts, vec![Action::Send { key: "q".into(), slots: vec![2, 3], hold: Hold::Tap, }] ); assert!(!e.should_intercept("e")); // passthrough ESDF e.mode = Mode::Off; assert!(!e.should_intercept("1")); assert!(!e.should_intercept("q")); } #[test] fn bar_sends_assist_to_others_and_key_to_all() { let mut e = sample(); let actions = e.fire("1", Hold::Tap).unwrap(); assert_eq!( actions, vec![ Action::Send { key: "Shift+F2".into(), slots: vec![2, 3], hold: Hold::Tap, }, Action::Send { key: "1".into(), slots: vec![1, 2, 3], hold: Hold::Tap, }, ] ); } /// Build a profile with one round-robin map and N slots. fn rr_sample(map_name: &str, target: &str, n: u32) -> Engine { let profile = Profile { name: "rr".into(), client: "wow-retail".into(), slots: n, window_match: Default::default(), passthrough: vec![], mode_default: Mode::Maps, repeater: Default::default(), game_binds: BTreeMap::new(), interact: Default::default(), session_hotkeys: BTreeMap::new(), characters: vec![], groups: BTreeMap::new(), maps: vec![Map { name: map_name.into(), hotkey: Hotkey("F8".into()), hold: false, steps: vec![Step { key: Some("g".into()), bind: None, delay_ms: None, target: target.into(), }], release_steps: vec![], }], video_fx: vec![], layout: Default::default(), }; Engine::new(profile).unwrap() } #[test] fn rr_cycles_one_two_three_around_three_presses() { let mut e = rr_sample("rez_cycle", "round_robin", 3); let slots1 = e.resolve_targets("round_robin", "rez_cycle").unwrap(); let slots2 = e.resolve_targets("round_robin", "rez_cycle").unwrap(); let slots3 = e.resolve_targets("round_robin", "rez_cycle").unwrap(); assert_eq!(slots1, vec![1]); assert_eq!(slots2, vec![2]); assert_eq!(slots3, vec![3]); } #[test] fn rr_wraps_back_to_one_after_slots_exhausted() { let mut e = rr_sample("rez_cycle", "rr", 3); let _ = e.resolve_targets("rr", "rez_cycle").unwrap(); let _ = e.resolve_targets("rr", "rez_cycle").unwrap(); let _ = e.resolve_targets("rr", "rez_cycle").unwrap(); let slots4 = e.resolve_targets("rr", "rez_cycle").unwrap(); assert_eq!(slots4, vec![1], "wraps after slots exhaust"); } #[test] fn rr_cursor_is_per_map() { let mut e = rr_sample("alpha", "rr", 3); let _ = e.resolve_targets("rr", "alpha").unwrap(); let _ = e.resolve_targets("rr", "alpha").unwrap(); // alpha is at slot 3; beta is fresh, so it returns slot 1. let beta = e.resolve_targets("rr", "beta").unwrap(); assert_eq!(beta, vec![1]); // Advancing alpha now goes to its next slot. let alpha = e.resolve_targets("rr", "alpha").unwrap(); assert_eq!(alpha, vec![3]); } #[test] fn rr_cursors_reset_on_fresh_engine() { let mut a = rr_sample("map", "rr", 3); let _ = a.resolve_targets("rr", "map").unwrap(); let _ = a.resolve_targets("rr", "map").unwrap(); let mut b = rr_sample("map", "rr", 3); assert_eq!(b.resolve_targets("rr", "map").unwrap(), vec![1]); } #[test] fn rr_works_through_fire_map() { let mut e = rr_sample("rez_cycle", "round_robin", 3); let a = e.fire("F8", Hold::Tap).unwrap(); let b = e.fire("F8", Hold::Tap).unwrap(); let c = e.fire("F8", Hold::Tap).unwrap(); let target = |a: Vec| -> Vec { match a.into_iter().next() { Some(Action::Send { slots, .. }) => slots, _ => panic!("expected send"), } }; assert_eq!(target(a), vec![1]); assert_eq!(target(b), vec![2]); assert_eq!(target(c), vec![3]); } }