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