Skip to main content

flatland_play_loop/
actions.rs

1//! Input action and overlay menu dispatch for any [`GameClient`] session.
2
3use flatland_client_lib::{
4    AutoNavigator, ClientKeyBindings, GameClient, PlayConnection, RotationEditorMode,
5};
6use flatland_client_ui::{
7    next_custom_preset, preset_deletable, InputAction, UiKeyCode, UiKeyEvent, UiKeyEventKind,
8    EDITOR_ABILITIES,
9};
10
11pub struct ActionCtx<'a> {
12    pub block_active: &'a mut bool,
13    pub auto_nav: &'a mut Option<AutoNavigator>,
14}
15
16/// Returns true if the key was consumed by a menu handler.
17pub async fn dispatch_menu_key<S: PlayConnection>(
18    client: &mut GameClient<S>,
19    key: UiKeyEvent,
20) -> bool {
21    if key.kind != UiKeyEventKind::Press {
22        return false;
23    }
24
25    if client.state.show_worker_rename {
26        match key.code {
27            UiKeyCode::Esc => client.cancel_worker_rename(),
28            UiKeyCode::Enter => {
29                if let Err(err) = client.confirm_worker_rename().await {
30                    client.state.push_log(format!("Rename: {err}"));
31                }
32            }
33            UiKeyCode::Backspace => {
34                client.state.rename_buffer.pop();
35            }
36            UiKeyCode::Char(c) => {
37                if client.state.rename_buffer.chars().count() < 32 {
38                    client.state.rename_buffer.push(c);
39                }
40            }
41            _ => {}
42        }
43        return true;
44    }
45
46    if client.state.show_inventory_menu {
47        if client.state.show_rename_prompt {
48            match key.code {
49                UiKeyCode::Esc => client.cancel_rename_prompt(),
50                UiKeyCode::Enter => {
51                    if let Err(err) = client.confirm_rename_prompt().await {
52                        client.state.push_log(format!("Rename: {err}"));
53                    }
54                }
55                UiKeyCode::Backspace => {
56                    client.state.rename_buffer.pop();
57                }
58                UiKeyCode::Char(c) => {
59                    if client.state.rename_buffer.len() < 32 {
60                        client.state.rename_buffer.push(c);
61                    }
62                }
63                _ => {}
64            }
65        } else if client.state.show_destroy_picker {
66            match key.code {
67                UiKeyCode::Esc => {
68                    if client.state.destroy_confirm_pending {
69                        client.cancel_destroy_confirm();
70                    } else {
71                        client.close_destroy_picker();
72                    }
73                }
74                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
75                    if !client.state.destroy_confirm_pending {
76                        client.destroy_picker_adjust_quantity(-1);
77                    }
78                }
79                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
80                    if !client.state.destroy_confirm_pending {
81                        client.destroy_picker_adjust_quantity(1);
82                    }
83                }
84                UiKeyCode::Char('a') => {
85                    if !client.state.destroy_confirm_pending {
86                        client.destroy_picker_set_quantity_max();
87                    }
88                }
89                UiKeyCode::Enter => {
90                    if let Err(err) = client.activate_inventory_selection().await {
91                        client.state.push_log(format!("Destroy failed: {err}"));
92                    }
93                }
94                _ => {}
95            }
96        } else if client.state.show_move_picker {
97            match key.code {
98                UiKeyCode::Esc => client.close_move_picker(),
99                UiKeyCode::Up | UiKeyCode::Char('k') => client.inventory_menu_move(-1),
100                UiKeyCode::Down | UiKeyCode::Char('j') => client.inventory_menu_move(1),
101                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
102                    client.move_picker_adjust_quantity(-1);
103                }
104                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
105                    client.move_picker_adjust_quantity(1);
106                }
107                UiKeyCode::Char('a') => client.move_picker_set_quantity_max(),
108                UiKeyCode::Enter => {
109                    if let Err(err) = client.activate_inventory_selection().await {
110                        client.state.push_log(format!("Move failed: {err}"));
111                    }
112                }
113                _ => {}
114            }
115        } else {
116            match key.code {
117                UiKeyCode::Char('b') => client.close_inventory_menu(),
118                UiKeyCode::Up | UiKeyCode::Char('k') => client.inventory_menu_move(-1),
119                UiKeyCode::Down | UiKeyCode::Char('j') => client.inventory_menu_move(1),
120                UiKeyCode::Enter => {
121                    if let Err(err) = client.activate_inventory_selection().await {
122                        client.state.push_log(format!("{err}"));
123                    }
124                }
125                UiKeyCode::Char('m') => {
126                    if let Err(err) = client.open_move_picker() {
127                        client.state.push_log(format!("{err}"));
128                    }
129                }
130                UiKeyCode::Char('e') => {
131                    if let Err(err) = client.use_selected_consumable().await {
132                        client.state.push_log(format!("Use: {err}"));
133                    }
134                }
135                UiKeyCode::Char('n') => {
136                    if let Err(err) = client.open_rename_prompt() {
137                        client.state.push_log(format!("{err}"));
138                    }
139                }
140                UiKeyCode::Char('d') => {
141                    if let Err(err) = client.drop_selected().await {
142                        client.state.push_log(format!("Drop: {err}"));
143                    }
144                }
145                UiKeyCode::Char('x') => {
146                    if let Err(err) = client.open_destroy_picker() {
147                        client.state.push_log(format!("Destroy: {err}"));
148                    }
149                }
150                UiKeyCode::Char('l') => {
151                    if let Err(err) = client.toggle_chest_lock_for_selection().await {
152                        client.state.push_log(format!("Lock: {err}"));
153                    }
154                }
155                UiKeyCode::Char('g') => {
156                    if let Err(err) = client.give_selected_inventory_to_worker().await {
157                        client.state.push_log(format!("Give: {err}"));
158                    }
159                }
160                UiKeyCode::Char('u') => {
161                    if let Err(err) = client.unequip_mainhand().await {
162                        client.state.push_log(format!("Unequip: {err}"));
163                    }
164                }
165                _ => {}
166            }
167        }
168        return true;
169    }
170
171    if client.state.show_keychain_menu {
172        match key.code {
173            UiKeyCode::Esc | UiKeyCode::Char(',') => client.close_keychain_menu(),
174            UiKeyCode::Up | UiKeyCode::Char('w') | UiKeyCode::Char('k') => {
175                client.keychain_menu_move(-1);
176            }
177            UiKeyCode::Down | UiKeyCode::Char('s') | UiKeyCode::Char('j') => {
178                client.keychain_menu_move(1);
179            }
180            UiKeyCode::Enter => {
181                if let Err(err) = client.activate_keychain_selection().await {
182                    client.state.push_log(format!("Keychain: {err}"));
183                }
184            }
185            _ => {}
186        }
187        return true;
188    }
189
190    if client.state.show_craft_menu {
191        match key.code {
192            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
193                client.close_craft_menu()
194            }
195            UiKeyCode::Up | UiKeyCode::Char('k') => client.craft_menu_move(-1),
196            UiKeyCode::Down | UiKeyCode::Char('j') => client.craft_menu_move(1),
197            UiKeyCode::Char('-') => client.craft_batch_adjust_quantity(-1),
198            UiKeyCode::Char('=') => client.craft_batch_adjust_quantity(1),
199            UiKeyCode::Char('a') => client.craft_batch_set_max(),
200            UiKeyCode::Enter => {
201                if let Err(err) = client.craft_menu_selection().await {
202                    client.state.push_log(format!("Craft failed: {err}"));
203                }
204            }
205            _ => {}
206        }
207        return true;
208    }
209
210    if client.state.show_quest_offer {
211        match key.code {
212            UiKeyCode::Esc => client.quest_offer_decline(),
213            UiKeyCode::Enter => {
214                if let Err(err) = client.quest_offer_accept().await {
215                    client.state.push_log(format!("Quest: {err}"));
216                }
217            }
218            _ => {}
219        }
220        return true;
221    }
222
223    if client.state.show_npc_chat {
224        match key.code {
225            UiKeyCode::Esc => {
226                if let Err(err) = client.npc_talk_close().await {
227                    client.state.push_log(format!("Talk close failed: {err}"));
228                }
229            }
230            UiKeyCode::Enter => {
231                if let Err(err) = client.npc_talk_send().await {
232                    client.state.push_log(format!("Talk failed: {err}"));
233                }
234            }
235            UiKeyCode::Backspace => {
236                if let Some(chat) = client.state.npc_chat.as_mut() {
237                    chat.input.pop();
238                }
239            }
240            UiKeyCode::Char(c) => {
241                if let Some(chat) = client.state.npc_chat.as_mut() {
242                    if !chat.pending && chat.input.len() < 300 {
243                        chat.input.push(c);
244                    }
245                }
246            }
247            _ => {}
248        }
249        return true;
250    }
251
252    if client.state.show_npc_verb_menu {
253        match key.code {
254            UiKeyCode::Esc => {
255                client.state.show_npc_verb_menu = false;
256                client.state.npc_verb_target = None;
257            }
258            UiKeyCode::Up | UiKeyCode::Char('k') => {
259                if client.state.npc_verb_index > 0 {
260                    client.state.npc_verb_index -= 1;
261                }
262            }
263            UiKeyCode::Down | UiKeyCode::Char('j') => {
264                let max = client.npc_verb_options().len().saturating_sub(1);
265                if client.state.npc_verb_index < max {
266                    client.state.npc_verb_index += 1;
267                }
268            }
269            UiKeyCode::Enter => {
270                if let Err(err) = client.confirm_npc_verb().await {
271                    client.state.push_log(format!("Interact failed: {err}"));
272                }
273            }
274            _ => {}
275        }
276        return true;
277    }
278
279    if client.state.show_shop_menu {
280        match key.code {
281            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
282                client.back_from_shop_menu()
283            }
284            UiKeyCode::Tab => client.shop_tab_toggle(),
285            UiKeyCode::Up | UiKeyCode::Char('k') => client.shop_menu_move(-1),
286            UiKeyCode::Down | UiKeyCode::Char('j') => client.shop_menu_move(1),
287            UiKeyCode::Char('-') => client.shop_quantity_adjust(-1),
288            UiKeyCode::Char('=') => client.shop_quantity_adjust(1),
289            UiKeyCode::Char('a') => client.shop_quantity_set_max(),
290            UiKeyCode::Enter => {
291                if let Err(err) = client.shop_confirm().await {
292                    client.state.push_log(format!("Trade failed: {err}"));
293                }
294            }
295            _ => {}
296        }
297        return true;
298    }
299
300    if client.state.show_quest_menu {
301        match key.code {
302            UiKeyCode::Esc => {
303                if client.state.quest_withdraw_confirm {
304                    client.state.quest_withdraw_confirm = false;
305                } else {
306                    client.state.show_quest_menu = false;
307                }
308            }
309            UiKeyCode::Up | UiKeyCode::Char('k') => client.quest_menu_move(-1),
310            UiKeyCode::Down | UiKeyCode::Char('j') => client.quest_menu_move(1),
311            UiKeyCode::Char('x') => client.quest_request_withdraw(),
312            UiKeyCode::Enter => {
313                if let Err(err) = client.quest_confirm_action().await {
314                    client.state.push_log(format!("Quest: {err}"));
315                }
316            }
317            _ => {}
318        }
319        return true;
320    }
321
322    if client.state.worker_route_editor.is_some() {
323        let at_root = client.re_at_root_sheet();
324        let sheet_index = client.re_sheet_index();
325        if at_root {
326            // Root: the ordered stop list.
327            match key.code {
328                UiKeyCode::Esc => client.close_worker_route_editor(),
329                UiKeyCode::Char('s') => {
330                    if let Err(err) = client.worker_route_editor_save().await {
331                        client.state.push_log(format!("Route: {err}"));
332                    }
333                }
334                UiKeyCode::Down => {
335                    if key.modifiers.contains_control() {
336                        client.worker_route_editor_move_selected(1);
337                    } else {
338                        client.worker_route_editor_select(1);
339                    }
340                }
341                UiKeyCode::Up => {
342                    if key.modifiers.contains_control() {
343                        client.worker_route_editor_move_selected(-1);
344                    } else {
345                        client.worker_route_editor_select(-1);
346                    }
347                }
348                // Select: vim j/k. Reorder: Ctrl+j = earlier (up), Ctrl+k = later (down)
349                // — matches "k moves down" and keeps arrow+Ctrl natural.
350                UiKeyCode::Char('j') => {
351                    if key.modifiers.contains_control() {
352                        client.worker_route_editor_move_selected(-1);
353                    } else {
354                        client.worker_route_editor_select(1);
355                    }
356                }
357                UiKeyCode::Char('k') => {
358                    if key.modifiers.contains_control() {
359                        client.worker_route_editor_move_selected(1);
360                    } else {
361                        client.worker_route_editor_select(-1);
362                    }
363                }
364                UiKeyCode::Char('d') | UiKeyCode::Delete => {
365                    client.worker_route_editor_delete_selected()
366                }
367                UiKeyCode::Char('x') => client.worker_route_editor_clear_stops(),
368                UiKeyCode::Char('a') => client.re_open_add_menu(),
369                UiKeyCode::Char('l') => client.re_open_bed_picker(),
370                UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
371                UiKeyCode::Enter => client.re_edit_selected_stop(),
372                _ => {}
373            }
374        } else {
375            // Inside a setup sheet: uniform picker keys.
376            match key.code {
377                UiKeyCode::Esc => client.re_sheet_back(),
378                UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
379                UiKeyCode::Char('j') | UiKeyCode::Down => client.re_sheet_move(1),
380                UiKeyCode::Char('k') | UiKeyCode::Up => client.re_sheet_move(-1),
381                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
382                    client.re_sheet_row_activate(sheet_index)
383                }
384                UiKeyCode::Char('[') | UiKeyCode::Char('-') => client.re_sheet_adjust(-1),
385                UiKeyCode::Char(']') | UiKeyCode::Char('=') => client.re_sheet_adjust(1),
386                _ => {}
387            }
388        }
389        return true;
390    }
391
392    if client.state.show_worker_give_picker {
393        match key.code {
394            UiKeyCode::Esc => client.close_worker_give_picker(),
395            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_picker_move(-1),
396            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_picker_move(1),
397            UiKeyCode::Enter => {
398                if let Err(err) = client.confirm_worker_give_picker().await {
399                    client.state.push_log(format!("Give: {err}"));
400                }
401            }
402            _ => {}
403        }
404        return true;
405    }
406
407    if client.state.show_worker_give_target_picker {
408        match key.code {
409            UiKeyCode::Esc => client.close_worker_give_target_picker(),
410            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_target_picker_move(-1),
411            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_target_picker_move(1),
412            UiKeyCode::Enter => {
413                if let Err(err) = client.confirm_worker_give_target_picker().await {
414                    client.state.push_log(format!("Give: {err}"));
415                }
416            }
417            _ => {}
418        }
419        return true;
420    }
421
422    if client.state.show_worker_take_picker {
423        match key.code {
424            UiKeyCode::Esc => client.close_worker_take_picker(),
425            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_take_picker_move(-1),
426            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_take_picker_move(1),
427            UiKeyCode::Enter => {
428                if let Err(err) = client.confirm_worker_take_picker().await {
429                    client.state.push_log(format!("Take: {err}"));
430                }
431            }
432            _ => {}
433        }
434        return true;
435    }
436
437    if client.state.show_worker_teach_picker {
438        match key.code {
439            UiKeyCode::Esc => client.close_worker_teach_picker(),
440            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_teach_picker_move(-1),
441            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_teach_picker_move(1),
442            UiKeyCode::Enter => {
443                if let Err(err) = client.confirm_worker_teach_picker().await {
444                    client.state.push_log(format!("Teach: {err}"));
445                }
446            }
447            _ => {}
448        }
449        return true;
450    }
451
452    if client.state.show_workers_menu {
453        match key.code {
454            UiKeyCode::Esc => client.state.show_workers_menu = false,
455            UiKeyCode::Up | UiKeyCode::Char('k') => client.workers_menu_move(-1),
456            UiKeyCode::Down | UiKeyCode::Char('j') => client.workers_menu_move(1),
457            UiKeyCode::Char('d') => {
458                if let Err(err) = client.workers_dismiss_selected().await {
459                    client.state.push_log(format!("Worker: {err}"));
460                }
461            }
462            UiKeyCode::Char('r') => {
463                if let Err(err) = client.hire_worker_laborer().await {
464                    client.state.push_log(format!("Hire: {err}"));
465                }
466            }
467            UiKeyCode::Char('e') => {
468                if let Err(err) = client.open_worker_route_editor_for_selected() {
469                    client.state.push_log(format!("Route: {err}"));
470                }
471            }
472            UiKeyCode::Char('n') => {
473                if let Err(err) = client.open_worker_rename() {
474                    client.state.push_log(format!("Rename: {err}"));
475                }
476            }
477            UiKeyCode::Char('g') => {
478                if let Err(err) = client.open_worker_give_picker() {
479                    client.state.push_log(format!("Give: {err}"));
480                }
481            }
482            UiKeyCode::Char('i') => {
483                if let Err(err) = client.open_worker_take_picker() {
484                    client.state.push_log(format!("Take: {err}"));
485                }
486            }
487            UiKeyCode::Char('t') => {
488                if let Err(err) = client.open_worker_teach_picker() {
489                    client.state.push_log(format!("Teach: {err}"));
490                }
491            }
492            UiKeyCode::Char('c') => client.toggle_workers_menu_compact(),
493            UiKeyCode::Enter => {
494                if let Err(err) = client.workers_confirm_action().await {
495                    client.state.push_log(format!("Worker: {err}"));
496                }
497            }
498            _ => {}
499        }
500        return true;
501    }
502
503    false
504}
505
506pub async fn dispatch_action<S: PlayConnection>(
507    client: &mut GameClient<S>,
508    keys: &ClientKeyBindings,
509    action: InputAction,
510    ctx: &mut ActionCtx<'_>,
511) {
512    match action {
513        InputAction::Harvest => {
514            if let Err(err) = client.harvest_nearest().await {
515                client.state.push_log(format!("Harvest failed: {err}"));
516            }
517        }
518        InputAction::Pickup => {
519            if client.pickup_nearest().await.is_err() {
520                if let Err(err) = client.pickup_nearest_container().await {
521                    client.state.push_log(format!("Pickup: {err}"));
522                }
523            }
524        }
525        InputAction::Craft => client.open_craft_menu(),
526        InputAction::Interact | InputAction::UseWorld => {
527            if let Err(err) = client.use_nearest().await {
528                client.state.push_log(format!("Use: {err}"));
529            }
530        }
531        InputAction::TestDamage => {
532            if let Err(err) = client.test_damage(25.0).await {
533                client.state.push_log(format!("Damage failed: {err}"));
534            }
535        }
536        InputAction::CycleCombatTarget { reverse } => {
537            if let Err(err) = client.cycle_combat_target(reverse).await {
538                client.state.push_log(format!("T1 target: {err}"));
539            }
540        }
541        InputAction::CycleCombatTargetT2 { reverse } => {
542            if let Err(err) = client.cycle_combat_target_slot(2, reverse).await {
543                client.state.push_log(format!("T2 target: {err}"));
544            }
545        }
546        InputAction::AdvanceRotationT1 => {
547            if let Err(err) = client.advance_rotation(1).await {
548                client.state.push_log(format!("T1 step: {err}"));
549            }
550        }
551        InputAction::AdvanceRotationT2 => {
552            if let Err(err) = client.advance_rotation(2).await {
553                client.state.push_log(format!("T2 step: {err}"));
554            }
555        }
556        InputAction::ToggleAutoT1 => {
557            if let Err(err) = client.toggle_auto_attack_slot(1).await {
558                client.state.push_log(format!("T1 auto: {err}"));
559            }
560        }
561        InputAction::ToggleAutoT2 => {
562            if let Err(err) = client.toggle_auto_attack_slot(2).await {
563                client.state.push_log(format!("T2 auto: {err}"));
564            }
565        }
566        InputAction::ToggleLoadout => {
567            client.state.show_rotation_editor = false;
568            client.state.rotation_editor.reset();
569            client.state.show_loadout_menu = !client.state.show_loadout_menu;
570            if client.state.show_loadout_menu {
571                *ctx.auto_nav = None;
572                let _ = client.stop().await;
573            }
574        }
575        InputAction::ToggleRotationEditor => {
576            client.state.show_loadout_menu = false;
577            if client.state.show_rotation_editor {
578                client.state.show_rotation_editor = false;
579                client.state.rotation_editor.reset();
580            } else {
581                client.state.show_rotation_editor = true;
582                client.state.rotation_editor.reset();
583                let max = client.state.rotation_presets.len();
584                if max > 0 {
585                    client.state.rotation_editor.list_index =
586                        client.state.rotation_editor.list_index.min(max - 1);
587                }
588            }
589            if client.state.show_rotation_editor {
590                *ctx.auto_nav = None;
591                let _ = client.stop().await;
592            }
593        }
594        InputAction::LoadoutMenuUp => {
595            if client.state.show_loadout_menu && client.state.loadout_menu_index > 0 {
596                client.state.loadout_menu_index -= 1;
597            }
598        }
599        InputAction::LoadoutMenuDown => {
600            if client.state.show_loadout_menu {
601                let max = client.state.rotation_presets.len();
602                if max > 0 {
603                    client.state.loadout_menu_index =
604                        (client.state.loadout_menu_index + 1).min(max - 1);
605                }
606            }
607        }
608        InputAction::LoadoutAssignT1 => {
609            let preset_id = {
610                let idx = client.state.loadout_menu_index;
611                client.state.rotation_presets.get(idx).map(|p| p.id.clone())
612            };
613            if let Some(preset_id) = preset_id {
614                if let Err(err) = client.assign_slot_preset(1, &preset_id).await {
615                    client.state.push_log(format!("Loadout: {err}"));
616                }
617            }
618        }
619        InputAction::LoadoutAssignT2 => {
620            let preset_id = {
621                let idx = client.state.loadout_menu_index;
622                client.state.rotation_presets.get(idx).map(|p| p.id.clone())
623            };
624            if let Some(preset_id) = preset_id {
625                if let Err(err) = client.assign_slot_preset(2, &preset_id).await {
626                    client.state.push_log(format!("Loadout: {err}"));
627                }
628            }
629        }
630        InputAction::CloseOverlay => {
631            client.state.show_loadout_menu = false;
632            client.state.show_rotation_editor = false;
633            client.state.rotation_editor.reset();
634        }
635        InputAction::ClearCombatTarget => {
636            if let Err(err) = client.clear_combat_target().await {
637                client.state.push_log(format!("Target: {err}"));
638            }
639        }
640        InputAction::ClearCombatTargetT2 => {
641            if let Err(err) = client.clear_combat_target_slot(2).await {
642                client.state.push_log(format!("T2 target: {err}"));
643            }
644        }
645        InputAction::Dodge => {
646            *ctx.auto_nav = None;
647            if let Err(err) = client.dodge().await {
648                client.state.push_log(format!("Dodge: {err}"));
649            }
650        }
651        InputAction::Lunge => {
652            *ctx.auto_nav = None;
653            let _ = client.stop().await;
654            if let Err(err) = client.lunge().await {
655                client.state.push_log(format!("Lunge: {err}"));
656            }
657        }
658        InputAction::DirectionalJump { forward, strafe } => {
659            *ctx.auto_nav = None;
660            let _ = client.stop().await;
661            if let Err(err) = client.directional_jump(forward, strafe).await {
662                client.state.push_log(format!("Jump: {err}"));
663            }
664        }
665        InputAction::CastHotbar { slot } => match keys.hotbar_ability(slot) {
666            Some(ability_id) => {
667                let ability_id = ability_id.to_string();
668                if let Err(err) = client.cast_hotbar_ability(&ability_id).await {
669                    client.state.push_log(format!("Hotbar {slot}: {err}"));
670                }
671            }
672            None => {
673                client.state.push_log(format!(
674                    "Hotbar {slot} unbound — set in client-settings.yaml"
675                ));
676            }
677        },
678        InputAction::ToggleBlock => {
679            *ctx.block_active = !*ctx.block_active;
680            if let Err(err) = client.set_block(*ctx.block_active).await {
681                client.state.push_log(format!("Block: {err}"));
682                *ctx.block_active = false;
683            }
684        }
685        InputAction::ToggleStats => client.toggle_stats(),
686        InputAction::ToggleInventory => client.toggle_inventory_menu(),
687        InputAction::ToggleKeychain => client.toggle_keychain_menu(),
688        InputAction::ToggleQuestMenu => client.toggle_quest_menu(),
689        InputAction::ToggleWorkersMenu => client.toggle_workers_menu(),
690        InputAction::QuestMenuUp => client.quest_menu_move(-1),
691        InputAction::QuestMenuDown => client.quest_menu_move(1),
692        InputAction::QuestWithdraw => client.quest_request_withdraw(),
693        InputAction::RotationEditorBack => {
694            let _ = client.back_on_esc();
695        }
696        InputAction::RotationEditorListUp
697        | InputAction::RotationEditorListDown
698        | InputAction::RotationEditorEdit
699        | InputAction::RotationEditorNew
700        | InputAction::RotationEditorDelete
701        | InputAction::RotationEditorAddAbility
702        | InputAction::RotationEditorRemoveAbility
703        | InputAction::RotationEditorMoveAbilityUp
704        | InputAction::RotationEditorMoveAbilityDown
705        | InputAction::RotationEditorAbilityUp
706        | InputAction::RotationEditorAbilityDown
707        | InputAction::RotationEditorPickerUp
708        | InputAction::RotationEditorPickerDown
709        | InputAction::RotationEditorPickAbility
710        | InputAction::RotationEditorRename
711        | InputAction::RotationEditorConfirmLabel
712        | InputAction::RotationEditorLabelBackspace
713        | InputAction::RotationEditorLabelChar(_)
714        | InputAction::RotationEditorSave => {
715            handle_rotation_editor_action(client, action).await;
716        }
717        InputAction::None
718        | InputAction::Quit
719        | InputAction::ToggleHelp
720        | InputAction::CycleHudView
721        | InputAction::StartChat { .. }
722        | InputAction::SubmitChat
723        | InputAction::CancelChat
724        | InputAction::ToggleSprintMode
725        | InputAction::ToggleMapTarget
726        | InputAction::ConfirmMapTarget
727        | InputAction::CancelMapTarget
728        | InputAction::MapTargetNudge { .. }
729        | InputAction::CancelAutoNav
730        | InputAction::StopMovement => {}
731    }
732}
733
734async fn handle_rotation_editor_action<S: PlayConnection>(
735    client: &mut GameClient<S>,
736    action: InputAction,
737) {
738    match action {
739        InputAction::RotationEditorListUp => {
740            if client.state.rotation_editor.list_index > 0 {
741                client.state.rotation_editor.list_index -= 1;
742            }
743        }
744        InputAction::RotationEditorListDown => {
745            let max = client.state.rotation_presets.len();
746            if max > 0 {
747                client.state.rotation_editor.list_index =
748                    (client.state.rotation_editor.list_index + 1).min(max - 1);
749            }
750        }
751        InputAction::RotationEditorEdit => {
752            let idx = client.state.rotation_editor.list_index;
753            if let Some(preset) = client.state.rotation_presets.get(idx).cloned() {
754                client.state.rotation_editor.draft = Some(preset);
755                client.state.rotation_editor.ability_index = 0;
756                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
757            }
758        }
759        InputAction::RotationEditorNew => {
760            let preset = next_custom_preset(&client.state.rotation_presets);
761            client.state.rotation_editor.list_index = client.state.rotation_presets.len();
762            client.state.rotation_editor.draft = Some(preset);
763            client.state.rotation_editor.ability_index = 0;
764            client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
765        }
766        InputAction::RotationEditorDelete => {
767            let idx = client.state.rotation_editor.list_index;
768            let preset_id = client.state.rotation_presets.get(idx).map(|p| p.id.clone());
769            if let Some(id) = preset_id {
770                if preset_deletable(&id) {
771                    if let Err(err) = client.delete_rotation_preset(&id).await {
772                        client.state.push_log(format!("Delete: {err}"));
773                    } else {
774                        let max = client.state.rotation_presets.len();
775                        client.state.rotation_editor.list_index = if max == 0 {
776                            0
777                        } else {
778                            client.state.rotation_editor.list_index.min(max - 1)
779                        };
780                    }
781                } else {
782                    client.state.push_log("Cannot delete built-in preset");
783                }
784            }
785        }
786        InputAction::RotationEditorBack => match client.state.rotation_editor.mode {
787            RotationEditorMode::EditLabel => {
788                client.state.rotation_editor.label_buffer.clear();
789                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
790            }
791            RotationEditorMode::PickAbility => {
792                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
793            }
794            RotationEditorMode::EditSequence => {
795                client.state.rotation_editor.draft = None;
796                client.state.rotation_editor.mode = RotationEditorMode::List;
797            }
798            RotationEditorMode::List => {}
799        },
800        InputAction::RotationEditorAddAbility => {
801            client.state.rotation_editor.picker_index = 0;
802            client.state.rotation_editor.mode = RotationEditorMode::PickAbility;
803        }
804        InputAction::RotationEditorRemoveAbility => {
805            let idx = client.state.rotation_editor.ability_index;
806            if let Some(draft) = &mut client.state.rotation_editor.draft {
807                if idx < draft.abilities.len() {
808                    draft.abilities.remove(idx);
809                    if client.state.rotation_editor.ability_index >= draft.abilities.len()
810                        && client.state.rotation_editor.ability_index > 0
811                    {
812                        client.state.rotation_editor.ability_index -= 1;
813                    }
814                }
815            }
816        }
817        InputAction::RotationEditorMoveAbilityUp => {
818            let i = client.state.rotation_editor.ability_index;
819            if let Some(draft) = &mut client.state.rotation_editor.draft {
820                if i > 0 && i < draft.abilities.len() {
821                    draft.abilities.swap(i, i - 1);
822                    client.state.rotation_editor.ability_index -= 1;
823                }
824            }
825        }
826        InputAction::RotationEditorMoveAbilityDown => {
827            let i = client.state.rotation_editor.ability_index;
828            if let Some(draft) = &mut client.state.rotation_editor.draft {
829                if i + 1 < draft.abilities.len() {
830                    draft.abilities.swap(i, i + 1);
831                    client.state.rotation_editor.ability_index += 1;
832                }
833            }
834        }
835        InputAction::RotationEditorAbilityUp => {
836            if client.state.rotation_editor.ability_index > 0 {
837                client.state.rotation_editor.ability_index -= 1;
838            }
839        }
840        InputAction::RotationEditorAbilityDown => {
841            if let Some(draft) = &client.state.rotation_editor.draft {
842                if !draft.abilities.is_empty() {
843                    client.state.rotation_editor.ability_index =
844                        (client.state.rotation_editor.ability_index + 1)
845                            .min(draft.abilities.len() - 1);
846                }
847            }
848        }
849        InputAction::RotationEditorPickerUp => {
850            if client.state.rotation_editor.picker_index > 0 {
851                client.state.rotation_editor.picker_index -= 1;
852            }
853        }
854        InputAction::RotationEditorPickerDown => {
855            if !EDITOR_ABILITIES.is_empty() {
856                client.state.rotation_editor.picker_index =
857                    (client.state.rotation_editor.picker_index + 1).min(EDITOR_ABILITIES.len() - 1);
858            }
859        }
860        InputAction::RotationEditorPickAbility => {
861            let pick = client.state.rotation_editor.picker_index;
862            if let Some(ability) = EDITOR_ABILITIES.get(pick) {
863                if let Some(draft) = &mut client.state.rotation_editor.draft {
864                    draft.abilities.push((*ability).to_string());
865                    client.state.rotation_editor.ability_index =
866                        draft.abilities.len().saturating_sub(1);
867                }
868                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
869            }
870        }
871        InputAction::RotationEditorRename => {
872            if let Some(draft) = &client.state.rotation_editor.draft {
873                client.state.rotation_editor.label_buffer = draft.label.clone();
874                client.state.rotation_editor.mode = RotationEditorMode::EditLabel;
875            }
876        }
877        InputAction::RotationEditorConfirmLabel => {
878            let label = client.state.rotation_editor.label_buffer.trim().to_string();
879            if label.is_empty() {
880                client.state.push_log("Label cannot be empty");
881            } else if let Some(draft) = &mut client.state.rotation_editor.draft {
882                draft.label = label;
883                client.state.rotation_editor.label_buffer.clear();
884                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
885            }
886        }
887        InputAction::RotationEditorLabelBackspace => {
888            client.state.rotation_editor.label_buffer.pop();
889        }
890        InputAction::RotationEditorLabelChar(c) => {
891            if client.state.rotation_editor.label_buffer.len() < 32 {
892                client.state.rotation_editor.label_buffer.push(c);
893            }
894        }
895        InputAction::RotationEditorSave => {
896            let draft = match client.state.rotation_editor.draft.clone() {
897                Some(d) => d,
898                None => return,
899            };
900            if draft.abilities.is_empty() {
901                client.state.push_log("Rotation needs at least one ability");
902                return;
903            }
904            let saved_id = draft.id.clone();
905            if let Err(err) = client.upsert_rotation_preset(draft).await {
906                client.state.push_log(format!("Save: {err}"));
907            } else {
908                client.state.rotation_editor.mode = RotationEditorMode::List;
909                client.state.rotation_editor.draft = None;
910                if let Some(i) = client
911                    .state
912                    .rotation_presets
913                    .iter()
914                    .position(|p| p.id == saved_id)
915                {
916                    client.state.rotation_editor.list_index = i;
917                }
918            }
919        }
920        _ => {}
921    }
922}