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