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    editor_ability_choices, next_custom_preset, preset_deletable, InputAction, UiKeyCode,
8    UiKeyEvent, UiKeyEventKind,
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    // Chat typing owns the keyboard — no gameplay binds while focused.
26    if client.state.social_chat.input_focused {
27        match key.code {
28            UiKeyCode::Esc => client.state.social_chat.unfocus(),
29            UiKeyCode::Enter => {
30                if let Err(err) = client.submit_social_chat_buffer().await {
31                    client.state.push_log(format!("Chat failed: {err}"));
32                }
33            }
34            UiKeyCode::Tab => client.state.social_chat.toggle_mode_speak_whisper(),
35            UiKeyCode::Backspace => {
36                client.state.social_chat.buffer.pop();
37            }
38            UiKeyCode::Char(c) => {
39                // Accept printable text; ignore lone control leftovers.
40                if !c.is_control()
41                    && client.state.social_chat.buffer.len()
42                        < flatland_client_lib::SocialChatState::MAX_BUF
43                {
44                    client.state.social_chat.buffer.push(c);
45                }
46            }
47            _ => {}
48        }
49        return true;
50    }
51
52    if client.state.show_worker_rename {
53        match key.code {
54            UiKeyCode::Esc => client.cancel_worker_rename(),
55            UiKeyCode::Enter => {
56                if let Err(err) = client.confirm_worker_rename().await {
57                    client.state.push_log(format!("Rename: {err}"));
58                }
59            }
60            UiKeyCode::Backspace => {
61                client.state.rename_buffer.pop();
62            }
63            UiKeyCode::Char(c) => {
64                if client.state.rename_buffer.chars().count() < 32 {
65                    client.state.rename_buffer.push(c);
66                }
67            }
68            _ => {}
69        }
70        return true;
71    }
72
73    if client.state.show_equip_menu {
74        match key.code {
75            UiKeyCode::Char('p') | UiKeyCode::Esc => {
76                client.toggle_equip_menu();
77            }
78            UiKeyCode::Up | UiKeyCode::Char('k') => {
79                if client.state.equip_menu_index > 0 {
80                    client.state.equip_menu_index -= 1;
81                }
82            }
83            UiKeyCode::Down | UiKeyCode::Char('j') => {
84                let n = flatland_client_lib::equip_paperdoll_rows(&client.state).len();
85                if n > 0 {
86                    client.state.equip_menu_index = (client.state.equip_menu_index + 1).min(n - 1);
87                }
88            }
89            UiKeyCode::PageUp => {
90                let n = flatland_client_lib::equip_paperdoll_rows(&client.state).len();
91                client.state.equip_menu_index =
92                    flatland_client_lib::page_list_index(client.state.equip_menu_index, -1, n);
93            }
94            UiKeyCode::PageDown => {
95                let n = flatland_client_lib::equip_paperdoll_rows(&client.state).len();
96                client.state.equip_menu_index =
97                    flatland_client_lib::page_list_index(client.state.equip_menu_index, 1, n);
98            }
99            UiKeyCode::Enter => {
100                if let Err(err) = client.activate_equip_selection().await {
101                    client.state.push_log(format!("Equip: {err}"));
102                }
103            }
104            _ => {}
105        }
106        return true;
107    }
108
109    if client.state.show_inventory_menu {
110        if client.state.show_rename_prompt {
111            match key.code {
112                UiKeyCode::Esc => client.cancel_rename_prompt(),
113                UiKeyCode::Enter => {
114                    if let Err(err) = client.confirm_rename_prompt().await {
115                        client.state.push_log(format!("Rename: {err}"));
116                    }
117                }
118                UiKeyCode::Backspace => {
119                    client.state.rename_buffer.pop();
120                }
121                UiKeyCode::Char(c) => {
122                    if client.state.rename_buffer.len() < 32 {
123                        client.state.rename_buffer.push(c);
124                    }
125                }
126                _ => {}
127            }
128        } else if client.state.show_destroy_picker {
129            match key.code {
130                UiKeyCode::Esc => {
131                    if client.state.destroy_confirm_pending {
132                        client.cancel_destroy_confirm();
133                    } else {
134                        client.close_destroy_picker();
135                    }
136                }
137                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
138                    if !client.state.destroy_confirm_pending {
139                        client.destroy_picker_adjust_quantity(-1);
140                    }
141                }
142                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
143                    if !client.state.destroy_confirm_pending {
144                        client.destroy_picker_adjust_quantity(1);
145                    }
146                }
147                UiKeyCode::Char('a') => {
148                    if !client.state.destroy_confirm_pending {
149                        client.destroy_picker_set_quantity_max();
150                    }
151                }
152                UiKeyCode::Enter => {
153                    if let Err(err) = client.activate_inventory_selection().await {
154                        client.state.push_log(format!("Destroy failed: {err}"));
155                    }
156                }
157                _ => {}
158            }
159        } else if client.state.show_grant_picker {
160            let filter_focused = client
161                .state
162                .grant_picker
163                .as_ref()
164                .map(|p| p.filter_focused)
165                .unwrap_or(false);
166            match key.code {
167                UiKeyCode::Esc => {
168                    if !client.clear_or_blur_inventory_filter() {
169                        client.close_grant_picker();
170                    }
171                }
172                UiKeyCode::PageUp => client.inventory_menu_page(-1),
173                UiKeyCode::PageDown => client.inventory_menu_page(1),
174                UiKeyCode::Char('/') if !filter_focused => client.focus_inventory_filter(),
175                UiKeyCode::Backspace if filter_focused => client.inventory_filter_backspace(),
176                UiKeyCode::Char(c) if filter_focused => client.append_inventory_filter_char(c),
177                UiKeyCode::Up | UiKeyCode::Char('k') if !filter_focused => {
178                    client.inventory_menu_move(-1)
179                }
180                UiKeyCode::Down | UiKeyCode::Char('j') if !filter_focused => {
181                    client.inventory_menu_move(1)
182                }
183                UiKeyCode::Enter if !filter_focused => {
184                    if let Err(err) = client.activate_inventory_selection().await {
185                        client.state.push_log(format!("Grant failed: {err}"));
186                    }
187                }
188                _ => {}
189            }
190        } else if client.state.show_move_picker {
191            let filter_focused = client
192                .state
193                .move_picker
194                .as_ref()
195                .map(|p| p.filter_focused)
196                .unwrap_or(false);
197            match key.code {
198                UiKeyCode::Esc => {
199                    if !client.clear_or_blur_inventory_filter() {
200                        client.close_move_picker();
201                    }
202                }
203                UiKeyCode::PageUp => client.inventory_menu_page(-1),
204                UiKeyCode::PageDown => client.inventory_menu_page(1),
205                UiKeyCode::Char('/') if !filter_focused => client.focus_inventory_filter(),
206                UiKeyCode::Backspace if filter_focused => client.inventory_filter_backspace(),
207                UiKeyCode::Char(c) if filter_focused => client.append_inventory_filter_char(c),
208                UiKeyCode::Up | UiKeyCode::Char('k') if !filter_focused => {
209                    client.inventory_menu_move(-1)
210                }
211                UiKeyCode::Down | UiKeyCode::Char('j') if !filter_focused => {
212                    client.inventory_menu_move(1)
213                }
214                UiKeyCode::Char('[') | UiKeyCode::Char('-') if !filter_focused => {
215                    client.move_picker_adjust_quantity(-1);
216                }
217                UiKeyCode::Char(']') | UiKeyCode::Char('=') if !filter_focused => {
218                    client.move_picker_adjust_quantity(1);
219                }
220                UiKeyCode::Char('a') if !filter_focused => client.move_picker_set_quantity_max(),
221                UiKeyCode::Enter if !filter_focused => {
222                    if let Err(err) = client.activate_inventory_selection().await {
223                        client.state.push_log(format!("Move failed: {err}"));
224                    }
225                }
226                _ => {}
227            }
228        } else if client.state.inventory_filter_focused {
229            match key.code {
230                UiKeyCode::Esc => {
231                    let _ = client.clear_or_blur_inventory_filter();
232                }
233                UiKeyCode::Enter => {
234                    client.state.inventory_filter_focused = false;
235                }
236                UiKeyCode::Backspace => client.inventory_filter_backspace(),
237                UiKeyCode::Char(c) => client.append_inventory_filter_char(c),
238                _ => {}
239            }
240        } else {
241            match key.code {
242                UiKeyCode::Esc => {
243                    if !client.clear_or_blur_inventory_filter() {
244                        client.close_inventory_menu();
245                    }
246                }
247                UiKeyCode::Char('b') => client.close_inventory_menu(),
248                UiKeyCode::Tab => client.cycle_inventory_tab(true),
249                UiKeyCode::BackTab => client.cycle_inventory_tab(false),
250                UiKeyCode::PageUp => client.inventory_menu_page(-1),
251                UiKeyCode::PageDown => client.inventory_menu_page(1),
252                UiKeyCode::Char('/') => client.focus_inventory_filter(),
253                UiKeyCode::Up | UiKeyCode::Char('k') => client.inventory_menu_move(-1),
254                UiKeyCode::Down | UiKeyCode::Char('j') => client.inventory_menu_move(1),
255                UiKeyCode::Enter => {
256                    if let Err(err) = client.activate_inventory_selection().await {
257                        client.state.push_log(format!("{err}"));
258                    }
259                }
260                UiKeyCode::Char('m') => {
261                    if let Err(err) = client.open_move_picker() {
262                        client.state.push_log(format!("{err}"));
263                    }
264                }
265                UiKeyCode::Char('e') => {
266                    if let Err(err) = client.use_selected_consumable().await {
267                        client.state.push_log(format!("Use: {err}"));
268                    }
269                }
270                UiKeyCode::Char('n') => {
271                    if let Err(err) = client.open_rename_prompt() {
272                        client.state.push_log(format!("{err}"));
273                    }
274                }
275                UiKeyCode::Char('d') => {
276                    if let Err(err) = client.drop_selected().await {
277                        client.state.push_log(format!("Drop: {err}"));
278                    }
279                }
280                UiKeyCode::Char('x') => {
281                    if let Err(err) = client.open_destroy_picker() {
282                        client.state.push_log(format!("Destroy: {err}"));
283                    }
284                }
285                UiKeyCode::Char('l') => {
286                    if let Err(err) = client.toggle_chest_lock_for_selection().await {
287                        client.state.push_log(format!("Lock: {err}"));
288                    }
289                }
290                UiKeyCode::Char('g') => {
291                    if let Err(err) = client.give_selected_inventory_to_worker().await {
292                        client.state.push_log(format!("Give: {err}"));
293                    }
294                }
295                UiKeyCode::Char('u') => {
296                    if let Err(err) = client.unequip_mainhand().await {
297                        client.state.push_log(format!("Unequip: {err}"));
298                    }
299                }
300                _ => {}
301            }
302        }
303        return true;
304    }
305
306    if client.state.show_keychain_menu {
307        match key.code {
308            UiKeyCode::Esc | UiKeyCode::Char(',') => client.close_keychain_menu(),
309            UiKeyCode::Up | UiKeyCode::Char('w') | UiKeyCode::Char('k') => {
310                client.keychain_menu_move(-1);
311            }
312            UiKeyCode::Down | UiKeyCode::Char('s') | UiKeyCode::Char('j') => {
313                client.keychain_menu_move(1);
314            }
315            UiKeyCode::PageUp => client.keychain_menu_page(-1),
316            UiKeyCode::PageDown => client.keychain_menu_page(1),
317            UiKeyCode::Enter => {
318                if let Err(err) = client.activate_keychain_selection().await {
319                    client.state.push_log(format!("Keychain: {err}"));
320                }
321            }
322            _ => {}
323        }
324        return true;
325    }
326
327    if client.state.show_craft_menu {
328        match key.code {
329            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
330                client.close_craft_menu()
331            }
332            UiKeyCode::Up | UiKeyCode::Char('k') => client.craft_menu_move(-1),
333            UiKeyCode::Down | UiKeyCode::Char('j') => client.craft_menu_move(1),
334            UiKeyCode::PageUp => client.craft_menu_page(-1),
335            UiKeyCode::PageDown => client.craft_menu_page(1),
336            UiKeyCode::Char('-') => client.craft_batch_adjust_quantity(-1),
337            UiKeyCode::Char('=') => client.craft_batch_adjust_quantity(1),
338            UiKeyCode::Char('a') => client.craft_batch_set_max(),
339            UiKeyCode::Enter => {
340                if let Err(err) = client.craft_menu_selection().await {
341                    client.state.push_log(format!("Craft failed: {err}"));
342                }
343            }
344            _ => {}
345        }
346        return true;
347    }
348
349    if client.state.show_quest_offer {
350        match key.code {
351            UiKeyCode::Esc => client.quest_offer_decline(),
352            UiKeyCode::Enter => {
353                if let Err(err) = client.quest_offer_accept().await {
354                    client.state.push_log(format!("Quest: {err}"));
355                }
356            }
357            _ => {}
358        }
359        return true;
360    }
361
362    if client.state.show_npc_chat {
363        match key.code {
364            UiKeyCode::Esc => {
365                if let Err(err) = client.npc_talk_close().await {
366                    client.state.push_log(format!("Talk close failed: {err}"));
367                }
368            }
369            UiKeyCode::Enter => {
370                if let Err(err) = client.npc_talk_send().await {
371                    client.state.push_log(format!("Talk failed: {err}"));
372                }
373            }
374            UiKeyCode::Backspace => {
375                if let Some(chat) = client.state.npc_chat.as_mut() {
376                    chat.input.pop();
377                }
378            }
379            UiKeyCode::Char(c) => {
380                if let Some(chat) = client.state.npc_chat.as_mut() {
381                    if !chat.pending && chat.input.len() < 300 {
382                        chat.input.push(c);
383                    }
384                }
385            }
386            _ => {}
387        }
388        return true;
389    }
390
391    if client.state.show_npc_verb_menu {
392        match key.code {
393            UiKeyCode::Esc => {
394                client.state.show_npc_verb_menu = false;
395                client.state.npc_verb_target = None;
396            }
397            UiKeyCode::Up | UiKeyCode::Char('k') => {
398                if client.state.npc_verb_index > 0 {
399                    client.state.npc_verb_index -= 1;
400                }
401            }
402            UiKeyCode::Down | UiKeyCode::Char('j') => {
403                let max = client.npc_verb_options().len().saturating_sub(1);
404                if client.state.npc_verb_index < max {
405                    client.state.npc_verb_index += 1;
406                }
407            }
408            UiKeyCode::Enter => {
409                if let Err(err) = client.confirm_npc_verb().await {
410                    client.state.push_log(format!("Interact failed: {err}"));
411                }
412            }
413            _ => {}
414        }
415        return true;
416    }
417
418    // Trade request accept/decline — not while typing chat.
419    if client.state.social_chat.pending_trade.is_some()
420        && !client.state.social_chat.input_focused
421        && !client.state.social_chat.picking_stone
422        && matches!(key.code, UiKeyCode::Char('y' | 'Y' | 'n' | 'N'))
423    {
424        let accept = matches!(key.code, UiKeyCode::Char('y' | 'Y'));
425        if let Err(err) = client.respond_pending_trade(accept).await {
426            client.state.push_log(format!("Trade respond failed: {err}"));
427        }
428        return true;
429    }
430
431    if client.state.player_verbs.open {
432        let opts = flatland_client_lib::PlayerVerbState::options();
433        match key.code {
434            UiKeyCode::Esc => client.state.player_verbs.close(),
435            UiKeyCode::Up | UiKeyCode::Char('k') => {
436                if client.state.player_verbs.index > 0 {
437                    client.state.player_verbs.index -= 1;
438                }
439            }
440            UiKeyCode::Down | UiKeyCode::Char('j') => {
441                let max = opts.len().saturating_sub(1);
442                if client.state.player_verbs.index < max {
443                    client.state.player_verbs.index += 1;
444                }
445            }
446            UiKeyCode::Enter => {
447                if let Err(err) = client.confirm_player_verb().await {
448                    client.state.push_log(format!("Interact failed: {err}"));
449                }
450            }
451            _ => {}
452        }
453        return true;
454    }
455
456    // Whisper-stone contact picker lives in the CHAT column (`g`).
457    if client.state.social_chat.picking_stone {
458        let contacts =
459            flatland_client_lib::contacts_from_stacks(&client.state.whisper_pouch_stacks);
460        match key.code {
461            UiKeyCode::Esc => {
462                client.state.social_chat.picking_stone = false;
463            }
464            UiKeyCode::Up | UiKeyCode::Char('k') => {
465                if client.state.social_chat.stone_pick_index > 0 {
466                    client.state.social_chat.stone_pick_index -= 1;
467                }
468            }
469            UiKeyCode::Down | UiKeyCode::Char('j') => {
470                let max = contacts.len().saturating_sub(1);
471                if client.state.social_chat.stone_pick_index < max {
472                    client.state.social_chat.stone_pick_index += 1;
473                }
474            }
475            UiKeyCode::Enter => {
476                if let Some(c) = contacts.get(client.state.social_chat.stone_pick_index) {
477                    if !c.blank {
478                        if let Some(peer) = client
479                            .state
480                            .entities
481                            .iter()
482                            .find(|e| e.id != client.state.entity_id && e.label == c.peer_label)
483                        {
484                            client
485                                .state
486                                .social_chat
487                                .focus_stone(peer.id, &c.peer_label);
488                        } else {
489                            client.state.social_chat.push_system(format!(
490                                "{} is not online in this region",
491                                c.peer_label
492                            ));
493                            client.state.social_chat.picking_stone = false;
494                        }
495                    }
496                }
497            }
498            _ => {}
499        }
500        return true;
501    }
502
503    if client.state.trade_ui.panel.is_some() {
504        // Quantity entry for presenting a stack.
505        if client.state.trade_ui.qty_entry.is_some() {
506            match key.code {
507                UiKeyCode::Esc => {
508                    client.state.trade_ui.qty_entry = None;
509                    client.state.trade_ui.picking_inventory = true;
510                }
511                UiKeyCode::Enter => {
512                    if let Err(err) = client.trade_confirm_qty_or_present().await {
513                        client.state.push_log(format!("Present failed: {err}"));
514                    }
515                }
516                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
517                    client.state.trade_ui.adjust_qty(-1);
518                }
519                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
520                    client.state.trade_ui.adjust_qty(1);
521                }
522                UiKeyCode::Char('a') | UiKeyCode::Char('A') => {
523                    client.state.trade_ui.set_qty_all();
524                }
525                UiKeyCode::Backspace => client.state.trade_ui.qty_backspace(),
526                UiKeyCode::Char(c) if c.is_ascii_digit() => {
527                    client.state.trade_ui.append_qty_digit(c);
528                }
529                _ => {}
530            }
531            return true;
532        }
533        match key.code {
534            UiKeyCode::Esc => {
535                if client.state.trade_ui.picking_inventory {
536                    client.state.trade_ui.picking_inventory = false;
537                } else if let Err(err) = client.trade_cancel().await {
538                    client.state.push_log(format!("Trade cancel failed: {err}"));
539                }
540            }
541            UiKeyCode::Char('r') => {
542                let ready = client
543                    .state
544                    .trade_ui
545                    .panel
546                    .as_ref()
547                    .map(|p| !p.i_ready)
548                    .unwrap_or(true);
549                if let Err(err) = client.trade_set_ready(ready).await {
550                    client.state.push_log(format!("Trade ready failed: {err}"));
551                }
552            }
553            UiKeyCode::Char('p') => {
554                client.state.trade_ui.picking_inventory = !client.state.trade_ui.picking_inventory;
555                client.state.trade_ui.qty_entry = None;
556            }
557            UiKeyCode::Up | UiKeyCode::Char('k') => {
558                if client.state.trade_ui.picking_inventory {
559                    if client.state.trade_ui.inventory_index > 0 {
560                        client.state.trade_ui.inventory_index -= 1;
561                    }
562                } else if client.state.trade_ui.select_index > 0 {
563                    client.state.trade_ui.select_index -= 1;
564                }
565            }
566            UiKeyCode::Down | UiKeyCode::Char('j') => {
567                if client.state.trade_ui.picking_inventory {
568                    let max = client.state.inventory_stacks.len().saturating_sub(1);
569                    if client.state.trade_ui.inventory_index < max {
570                        client.state.trade_ui.inventory_index += 1;
571                    }
572                }
573            }
574            UiKeyCode::Enter => {
575                if client.state.trade_ui.picking_inventory {
576                    if let Err(err) = client.trade_confirm_qty_or_present().await {
577                        client.state.push_log(format!("Present failed: {err}"));
578                    }
579                }
580            }
581            _ => {}
582        }
583        return true;
584    }
585
586    if client.state.whisper_pouch_ui.open {
587        let contacts =
588            flatland_client_lib::contacts_from_stacks(&client.state.whisper_pouch_stacks);
589        match key.code {
590            UiKeyCode::Esc => client.state.whisper_pouch_ui.open = false,
591            UiKeyCode::Up | UiKeyCode::Char('k') => {
592                if client.state.whisper_pouch_ui.index > 0 {
593                    client.state.whisper_pouch_ui.index -= 1;
594                }
595            }
596            UiKeyCode::Down | UiKeyCode::Char('j') => {
597                let max = contacts.len().saturating_sub(1);
598                if client.state.whisper_pouch_ui.index < max {
599                    client.state.whisper_pouch_ui.index += 1;
600                }
601            }
602            UiKeyCode::Enter => {
603                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
604                    if !c.blank {
605                        // Resolve online peer by label match for stone chat.
606                        if let Some(peer) = client
607                            .state
608                            .entities
609                            .iter()
610                            .find(|e| e.id != client.state.entity_id && e.label == c.peer_label)
611                        {
612                            client
613                                .state
614                                .social_chat
615                                .open_stone(peer.id, &c.peer_label);
616                            client.state.whisper_pouch_ui.open = false;
617                        } else {
618                            client.state.push_log(format!(
619                                "{} is not online in this region",
620                                c.peer_label
621                            ));
622                        }
623                    }
624                }
625            }
626            UiKeyCode::Char('x') | UiKeyCode::Char('d') => {
627                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
628                    let id = c.instance_id;
629                    if let Err(err) = client.destroy_whisper_stone(id).await {
630                        client.state.push_log(format!("Destroy stone failed: {err}"));
631                    }
632                }
633            }
634            _ => {}
635        }
636        return true;
637    }
638
639    if client.state.bank_panel.is_some() {
640        match &client.state.bank_ui_mode {
641            flatland_client_lib::BankUiMode::Menu => match key.code {
642                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
643                    if let Err(err) = client.close_bank_panel().await {
644                        client.state.push_log(format!("Bank close failed: {err}"));
645                    }
646                }
647                UiKeyCode::Up | UiKeyCode::Char('k') => client.bank_menu_move(-1),
648                UiKeyCode::Down | UiKeyCode::Char('j') => client.bank_menu_move(1),
649                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
650                    if let Err(err) = client.confirm_bank_menu().await {
651                        client.state.push_log(format!("Bank action failed: {err}"));
652                    }
653                }
654                UiKeyCode::Char('d') => {
655                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::DepositAmount {
656                        input: String::new(),
657                    };
658                }
659                UiKeyCode::Char('w') => {
660                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::WithdrawAmount {
661                        input: String::new(),
662                    };
663                }
664                UiKeyCode::Char('t') => {
665                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::TransferName {
666                        input: String::new(),
667                    };
668                }
669                _ => {}
670            },
671            _ => match key.code {
672                UiKeyCode::Esc | UiKeyCode::Char('[') => client.bank_transfer_back(),
673                UiKeyCode::Enter => {
674                    if let Err(err) = client.confirm_bank_menu().await {
675                        client.state.push_log(format!("Bank action failed: {err}"));
676                    }
677                }
678                UiKeyCode::Backspace => client.bank_transfer_backspace(),
679                UiKeyCode::Char(c) => client.bank_transfer_append_char(c),
680                _ => {}
681            },
682        }
683        return true;
684    }
685
686    if client.state.storage_panel.is_some() {
687        match &client.state.storage_ui_mode {
688            flatland_client_lib::StorageUiMode::Menu => match key.code {
689                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
690                    if let Err(err) = client.close_storage_panel().await {
691                        client.state.push_log(format!("Storage close failed: {err}"));
692                    }
693                }
694                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_menu_move(-1),
695                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_menu_move(1),
696                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
697                    if let Err(err) = client.confirm_storage_menu().await {
698                        client.state.push_log(format!("Storage action failed: {err}"));
699                    }
700                }
701                UiKeyCode::Char('s') => {
702                    let opts = client.state.storage_store_options();
703                    if opts.is_empty() {
704                        client.state.push_log("Nothing loose to store.");
705                    } else {
706                        client.state.storage_ui_mode =
707                            flatland_client_lib::StorageUiMode::StorePick { index: 0 };
708                    }
709                }
710                UiKeyCode::Char('t') => {
711                    let opts = client.state.storage_vault_options();
712                    if opts.is_empty() {
713                        client.state.push_log("Vault is empty.");
714                    } else {
715                        client.state.storage_ui_mode =
716                            flatland_client_lib::StorageUiMode::TakePick { index: 0 };
717                    }
718                }
719                _ => {}
720            },
721            flatland_client_lib::StorageUiMode::StoreAmount { .. }
722            | flatland_client_lib::StorageUiMode::TakeAmount { .. }
723            | flatland_client_lib::StorageUiMode::ShipAmount { .. } => match key.code {
724                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
725                UiKeyCode::Enter => {
726                    if let Err(err) = client.confirm_storage_menu().await {
727                        client.state.push_log(format!("Storage action failed: {err}"));
728                    }
729                }
730                UiKeyCode::Backspace => client.storage_amount_backspace(),
731                UiKeyCode::Char(c) => client.storage_amount_append_char(c),
732                _ => {}
733            },
734            _ => match key.code {
735                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
736                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_pick_move(-1),
737                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_pick_move(1),
738                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
739                    if let Err(err) = client.confirm_storage_menu().await {
740                        client.state.push_log(format!("Storage action failed: {err}"));
741                    }
742                }
743                _ => {}
744            },
745        }
746        return true;
747    }
748
749    if client.state.market_panel.is_some() {
750        let filter_focused = client.state.market_filter_focused;
751        match &client.state.market_ui_mode {
752            flatland_client_lib::MarketUiMode::Browse => {
753                if filter_focused {
754                    match key.code {
755                        UiKeyCode::Esc => {
756                            let _ = client.clear_or_blur_market_filter();
757                        }
758                        UiKeyCode::Enter => {
759                            client.state.market_filter_focused = false;
760                        }
761                        UiKeyCode::Backspace => client.market_filter_backspace(),
762                        UiKeyCode::Char(c) => client.append_market_filter_char(c),
763                        _ => {}
764                    }
765                    return true;
766                }
767                match key.code {
768                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
769                    if client.state.market_buy_confirm.is_some() {
770                        client.state.market_buy_confirm = None;
771                    } else if client.clear_or_blur_market_filter() {
772                        // cleared search / category stays
773                    } else if let Err(err) = client.close_market_panel().await {
774                        client.state.push_log(format!("Market close failed: {err}"));
775                    }
776                }
777                UiKeyCode::Up | UiKeyCode::Char('k') => {
778                    if client.state.market_buy_confirm.is_none() {
779                        client.market_move_selection(-1);
780                    }
781                }
782                UiKeyCode::Down | UiKeyCode::Char('j') => {
783                    if client.state.market_buy_confirm.is_none() {
784                        client.market_move_selection(1);
785                    }
786                }
787                UiKeyCode::PageUp => {
788                    if client.state.market_buy_confirm.is_none() {
789                        client.market_page_selection(-1);
790                    }
791                }
792                UiKeyCode::PageDown => {
793                    if client.state.market_buy_confirm.is_none() {
794                        client.market_page_selection(1);
795                    }
796                }
797                UiKeyCode::Tab => {
798                    if client.state.market_buy_confirm.is_none() {
799                        client.market_cycle_category(1);
800                    }
801                }
802                UiKeyCode::Char('/') => client.focus_market_filter(),
803                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
804                    if let Err(err) = client.market_activate_selection().await {
805                        client.state.push_log(format!("Market action failed: {err}"));
806                    }
807                }
808                UiKeyCode::Char('l') => client.market_begin_list(),
809                _ => {}
810            }
811            }
812            flatland_client_lib::MarketUiMode::ListAmount { .. }
813            | flatland_client_lib::MarketUiMode::ListPrice { .. } => match key.code {
814                UiKeyCode::Esc | UiKeyCode::Char('[') => client.market_ui_back(),
815                UiKeyCode::Enter => {
816                    if let Err(err) = client.confirm_market_list_step().await {
817                        client.state.push_log(format!("Market list failed: {err}"));
818                    }
819                }
820                UiKeyCode::Backspace => client.market_list_amount_backspace(),
821                UiKeyCode::Char(c) => client.market_list_amount_append_char(c),
822                _ => {}
823            },
824            _ => {
825                if filter_focused {
826                    match key.code {
827                        UiKeyCode::Esc => {
828                            let _ = client.clear_or_blur_market_filter();
829                        }
830                        UiKeyCode::Enter => {
831                            client.state.market_filter_focused = false;
832                        }
833                        UiKeyCode::Backspace => client.market_filter_backspace(),
834                        UiKeyCode::Char(c) => client.append_market_filter_char(c),
835                        _ => {}
836                    }
837                    return true;
838                }
839                match key.code {
840                UiKeyCode::Esc | UiKeyCode::Char('[') => {
841                    if !client.clear_or_blur_market_filter() {
842                        client.market_ui_back();
843                    }
844                }
845                UiKeyCode::Up | UiKeyCode::Char('k') => client.market_list_move(-1),
846                UiKeyCode::Down | UiKeyCode::Char('j') => client.market_list_move(1),
847                UiKeyCode::PageUp => client.market_list_page(-1),
848                UiKeyCode::PageDown => client.market_list_page(1),
849                UiKeyCode::Tab => client.market_cycle_category(1),
850                UiKeyCode::Char('/') => client.focus_market_filter(),
851                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
852                    if let Err(err) = client.confirm_market_list_step().await {
853                        client.state.push_log(format!("Market list failed: {err}"));
854                    }
855                }
856                _ => {}
857            }
858            }
859        }
860        return true;
861    }
862
863    if client.state.show_shop_menu {
864        match key.code {
865            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
866                if let Err(err) = client.back_from_shop_menu().await {
867                    client.state.push_log(format!("Shop close failed: {err}"));
868                }
869            }
870            UiKeyCode::Tab => client.shop_tab_toggle(),
871            UiKeyCode::Up | UiKeyCode::Char('k') => client.shop_menu_move(-1),
872            UiKeyCode::Down | UiKeyCode::Char('j') => client.shop_menu_move(1),
873            UiKeyCode::PageUp => client.shop_menu_page(-1),
874            UiKeyCode::PageDown => client.shop_menu_page(1),
875            UiKeyCode::Char('-') => client.shop_quantity_adjust(-1),
876            UiKeyCode::Char('=') => client.shop_quantity_adjust(1),
877            UiKeyCode::Char('a') => client.shop_quantity_set_max(),
878            UiKeyCode::Enter => {
879                if let Err(err) = client.shop_confirm().await {
880                    client.state.push_log(format!("Trade failed: {err}"));
881                }
882            }
883            _ => {}
884        }
885        return true;
886    }
887
888    if client.state.show_quest_menu {
889        match key.code {
890            UiKeyCode::Esc => {
891                if client.state.quest_withdraw_confirm {
892                    client.state.quest_withdraw_confirm = false;
893                } else {
894                    client.state.show_quest_menu = false;
895                }
896            }
897            UiKeyCode::Up | UiKeyCode::Char('k') => client.quest_menu_move(-1),
898            UiKeyCode::Down | UiKeyCode::Char('j') => client.quest_menu_move(1),
899            UiKeyCode::PageUp => client.quest_menu_page(-1),
900            UiKeyCode::PageDown => client.quest_menu_page(1),
901            UiKeyCode::Char('x') => client.quest_request_withdraw(),
902            UiKeyCode::Enter => {
903                if let Err(err) = client.quest_confirm_action().await {
904                    client.state.push_log(format!("Quest: {err}"));
905                }
906            }
907            _ => {}
908        }
909        return true;
910    }
911
912    if client.state.worker_route_editor.is_some() {
913        let at_root = client.re_at_root_sheet();
914        if at_root {
915            // Root: the ordered stop list.
916            match key.code {
917                UiKeyCode::Esc => client.close_worker_route_editor(),
918                UiKeyCode::Char('s') => {
919                    if let Err(err) = client.worker_route_editor_save().await {
920                        client.state.push_log(format!("Route: {err}"));
921                    }
922                }
923                UiKeyCode::Down => {
924                    if key.modifiers.contains_control() {
925                        client.worker_route_editor_move_selected(1);
926                    } else {
927                        client.worker_route_editor_select(1);
928                    }
929                }
930                UiKeyCode::Up => {
931                    if key.modifiers.contains_control() {
932                        client.worker_route_editor_move_selected(-1);
933                    } else {
934                        client.worker_route_editor_select(-1);
935                    }
936                }
937                // Select: vim j/k. Reorder: Ctrl+j = earlier (up), Ctrl+k = later (down)
938                // — matches "k moves down" and keeps arrow+Ctrl natural.
939                UiKeyCode::Char('j') => {
940                    if key.modifiers.contains_control() {
941                        client.worker_route_editor_move_selected(-1);
942                    } else {
943                        client.worker_route_editor_select(1);
944                    }
945                }
946                UiKeyCode::Char('k') => {
947                    if key.modifiers.contains_control() {
948                        client.worker_route_editor_move_selected(1);
949                    } else {
950                        client.worker_route_editor_select(-1);
951                    }
952                }
953                UiKeyCode::Char('d') | UiKeyCode::Delete => {
954                    client.worker_route_editor_delete_selected()
955                }
956                UiKeyCode::Char('x') => client.worker_route_editor_clear_stops(),
957                UiKeyCode::Char('a') => client.re_open_add_menu(),
958                UiKeyCode::Char('l') => client.re_open_bed_picker(),
959                UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
960                UiKeyCode::Enter => client.re_edit_selected_stop(),
961                _ => {}
962            }
963        } else {
964            // Inside a setup sheet: uniform picker keys.
965            let filter_focused = client
966                .state
967                .worker_route_editor
968                .as_ref()
969                .map(|ed| ed.sheet_filter_focused)
970                .unwrap_or(false);
971            let sheet_index = client.re_sheet_index();
972
973            if filter_focused {
974                // Search mode: every key is filter text until Enter (no j/k/list binds).
975                match key.code {
976                    UiKeyCode::Esc => {
977                        let _ = client.clear_or_blur_re_sheet_filter();
978                    }
979                    UiKeyCode::Enter => client.re_blur_sheet_filter_keep_text(),
980                    UiKeyCode::Backspace => client.re_sheet_filter_backspace(),
981                    UiKeyCode::Char(c) => client.re_append_sheet_filter_char(c),
982                    _ => {}
983                }
984            } else {
985                match key.code {
986                    UiKeyCode::Esc => {
987                        if !client.clear_or_blur_re_sheet_filter() {
988                            client.re_sheet_back();
989                        }
990                    }
991                    UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
992                    UiKeyCode::PageUp => client.re_sheet_page(-1),
993                    UiKeyCode::PageDown => client.re_sheet_page(1),
994                    UiKeyCode::Char('/') => client.re_focus_sheet_filter(),
995                    UiKeyCode::Char('j') | UiKeyCode::Down => client.re_sheet_move(1),
996                    UiKeyCode::Char('k') | UiKeyCode::Up => client.re_sheet_move(-1),
997                    UiKeyCode::Enter | UiKeyCode::Char(' ') => {
998                        client.re_sheet_row_activate(sheet_index)
999                    }
1000                    UiKeyCode::Char('[') | UiKeyCode::Char('-') => client.re_sheet_adjust(-1),
1001                    UiKeyCode::Char(']') | UiKeyCode::Char('=') => client.re_sheet_adjust(1),
1002                    _ => {}
1003                }
1004            }
1005        }
1006        return true;
1007    }
1008
1009    if client.state.show_plant_menu {
1010        match key.code {
1011            UiKeyCode::Esc => client.close_plant_menu(),
1012            UiKeyCode::Up | UiKeyCode::Char('k') => client.plant_menu_move(-1),
1013            UiKeyCode::Down | UiKeyCode::Char('j') => client.plant_menu_move(1),
1014            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
1015                client.plant_menu_adjust_quantity(-1)
1016            }
1017            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
1018                client.plant_menu_adjust_quantity(1)
1019            }
1020            UiKeyCode::Char('a') | UiKeyCode::Char('A') => client.plant_menu_set_quantity_max(),
1021            UiKeyCode::Enter | UiKeyCode::Char('f') | UiKeyCode::Char('F') => {
1022                if let Err(err) = client.confirm_plant_menu().await {
1023                    client.state.push_log(format!("Plant: {err}"));
1024                }
1025            }
1026            _ => {}
1027        }
1028        return true;
1029    }
1030
1031    if client.state.show_farm_access {
1032        match key.code {
1033            UiKeyCode::Esc => client.close_farm_access_panel(),
1034            UiKeyCode::Up | UiKeyCode::Char('k') => client.farm_access_move(-1),
1035            UiKeyCode::Down | UiKeyCode::Char('j') => client.farm_access_move(1),
1036            UiKeyCode::Enter | UiKeyCode::Char(' ') => {
1037                if let Err(err) = client.farm_access_activate().await {
1038                    client.state.push_log(format!("Farm access: {err}"));
1039                }
1040            }
1041            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
1042                if let Err(err) = client.farm_access_adjust_discount(-100).await {
1043                    client.state.push_log(format!("Farm access: {err}"));
1044                }
1045            }
1046            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
1047                if let Err(err) = client.farm_access_adjust_discount(100).await {
1048                    client.state.push_log(format!("Farm access: {err}"));
1049                }
1050            }
1051            _ => {}
1052        }
1053        return true;
1054    }
1055
1056    if client.state.show_worker_give_picker {
1057        match key.code {
1058            UiKeyCode::Esc => client.close_worker_give_picker(),
1059            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_picker_move(-1),
1060            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_picker_move(1),
1061            UiKeyCode::Enter => {
1062                if let Err(err) = client.confirm_worker_give_picker().await {
1063                    client.state.push_log(format!("Give: {err}"));
1064                }
1065            }
1066            _ => {}
1067        }
1068        return true;
1069    }
1070
1071    if client.state.show_worker_give_target_picker {
1072        match key.code {
1073            UiKeyCode::Esc => client.close_worker_give_target_picker(),
1074            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_target_picker_move(-1),
1075            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_target_picker_move(1),
1076            UiKeyCode::Enter => {
1077                if let Err(err) = client.confirm_worker_give_target_picker().await {
1078                    client.state.push_log(format!("Give: {err}"));
1079                }
1080            }
1081            _ => {}
1082        }
1083        return true;
1084    }
1085
1086    if client.state.show_worker_take_picker {
1087        match key.code {
1088            UiKeyCode::Esc => client.close_worker_take_picker(),
1089            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_take_picker_move(-1),
1090            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_take_picker_move(1),
1091            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
1092                client.worker_take_picker_adjust_quantity(-1)
1093            }
1094            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
1095                client.worker_take_picker_adjust_quantity(1)
1096            }
1097            UiKeyCode::Char('a') => client.worker_take_picker_set_quantity_max(),
1098            UiKeyCode::Enter => {
1099                if let Err(err) = client.confirm_worker_take_picker().await {
1100                    client.state.push_log(format!("Take: {err}"));
1101                }
1102            }
1103            _ => {}
1104        }
1105        return true;
1106    }
1107
1108    if client.state.show_worker_teach_picker {
1109        match key.code {
1110            UiKeyCode::Esc => client.close_worker_teach_picker(),
1111            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_teach_picker_move(-1),
1112            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_teach_picker_move(1),
1113            UiKeyCode::Enter => {
1114                if let Err(err) = client.confirm_worker_teach_picker().await {
1115                    client.state.push_log(format!("Teach: {err}"));
1116                }
1117            }
1118            _ => {}
1119        }
1120        return true;
1121    }
1122
1123    if client.state.show_workers_menu {
1124        match key.code {
1125            UiKeyCode::Esc => {
1126                if let Err(err) = client.close_workers_menu().await {
1127                    client.state.push_log(format!("Worker: {err}"));
1128                }
1129            }
1130            UiKeyCode::Up | UiKeyCode::Char('k') => client.workers_menu_move(-1),
1131            UiKeyCode::Down | UiKeyCode::Char('j') => client.workers_menu_move(1),
1132            UiKeyCode::PageUp => client.workers_menu_page(-1),
1133            UiKeyCode::PageDown => client.workers_menu_page(1),
1134            UiKeyCode::Char('d') => {
1135                if let Err(err) = client.workers_dismiss_selected().await {
1136                    client.state.push_log(format!("Worker: {err}"));
1137                }
1138            }
1139            UiKeyCode::Char('r') => {
1140                if let Err(err) = client.hire_worker_laborer().await {
1141                    client.state.push_log(format!("Hire: {err}"));
1142                }
1143            }
1144            UiKeyCode::Char('e') => {
1145                if let Err(err) = client.open_worker_route_editor_for_selected() {
1146                    client.state.push_log(format!("Route: {err}"));
1147                }
1148            }
1149            UiKeyCode::Char('n') => {
1150                if let Err(err) = client.open_worker_rename() {
1151                    client.state.push_log(format!("Rename: {err}"));
1152                }
1153            }
1154            UiKeyCode::Char('g') => {
1155                if let Err(err) = client.open_worker_give_picker() {
1156                    client.state.push_log(format!("Give: {err}"));
1157                }
1158            }
1159            UiKeyCode::Char('i') => {
1160                if let Err(err) = client.open_worker_take_picker() {
1161                    client.state.push_log(format!("Take: {err}"));
1162                }
1163            }
1164            UiKeyCode::Char('t') => {
1165                if let Err(err) = client.open_worker_teach_picker() {
1166                    client.state.push_log(format!("Teach: {err}"));
1167                }
1168            }
1169            UiKeyCode::Char('c') => client.toggle_workers_menu_compact(),
1170            UiKeyCode::Enter => {
1171                if let Err(err) = client.workers_confirm_action().await {
1172                    client.state.push_log(format!("Worker: {err}"));
1173                }
1174            }
1175            _ => {}
1176        }
1177        return true;
1178    }
1179
1180    false
1181}
1182
1183pub async fn dispatch_action<S: PlayConnection>(
1184    client: &mut GameClient<S>,
1185    _keys: &ClientKeyBindings,
1186    action: InputAction,
1187    ctx: &mut ActionCtx<'_>,
1188) {
1189    match action {
1190        InputAction::StartChat { whisper } => {
1191            if whisper {
1192                // Stone contacts: pick inside CHAT column (no separate popup).
1193                client.state.whisper_pouch_ui.open = false;
1194                client.state.social_chat.picking_stone = true;
1195                client.state.social_chat.stone_pick_index = 0;
1196                client.state.social_chat.input_focused = false;
1197                client.state.social_chat.push_system(
1198                    "Whisper stones — ↑↓ select · Enter · Esc cancel",
1199                );
1200            } else {
1201                client.state.social_chat.focus_nearby();
1202            }
1203        }
1204        InputAction::Harvest => {
1205            if let Err(err) = client.harvest_nearest().await {
1206                client.state.push_log(format!("Harvest failed: {err}"));
1207            }
1208        }
1209        InputAction::Pickup => {
1210            if client.pickup_nearest().await.is_err() {
1211                if let Err(err) = client.pickup_nearest_container().await {
1212                    client.state.push_log(format!("Pickup: {err}"));
1213                }
1214            }
1215        }
1216        InputAction::Craft => client.open_craft_menu(),
1217        InputAction::Interact | InputAction::UseWorld => {
1218            if let Err(err) = client.use_nearest().await {
1219                client.state.push_log(format!("Use: {err}"));
1220            }
1221        }
1222        InputAction::ClaimPlotBegin => {
1223            if let Err(err) = client.try_begin_claim_mode().await {
1224                client.state.push_log(format!("Claim: {err}"));
1225            }
1226        }
1227        InputAction::FarmCultivate => {
1228            if let Err(err) = client.farm_cultivate_underfoot().await {
1229                client.state.push_log(format!("Cultivate: {err}"));
1230            }
1231        }
1232        InputAction::FarmPlant => {
1233            if let Err(err) = client.farm_plant_underfoot().await {
1234                client.state.push_log(format!("Plant: {err}"));
1235            }
1236        }
1237        InputAction::ClaimConfirm => {
1238            if let Err(err) = client.confirm_buy_plot().await {
1239                client.state.push_log(format!("Claim: {err}"));
1240            }
1241        }
1242        InputAction::ClaimCancel => {
1243            client.cancel_claim_mode();
1244        }
1245        InputAction::ClaimNudge { dw, dh } => {
1246            client.claim_nudge(dw, dh);
1247        }
1248        InputAction::ClaimMoveNudge { dx, dy } => {
1249            client.claim_move_nudge(dx, dy);
1250        }
1251        InputAction::ClaimPreset { w, h } => {
1252            client.claim_set_preset(w, h);
1253        }
1254        InputAction::RelocateBeginNearest => {
1255            if let Err(err) = client.try_begin_relocate_nearest() {
1256                client.state.push_log(format!("Relocate: {err}"));
1257            }
1258        }
1259        InputAction::RelocateConfirm => {
1260            if let Err(err) = client.confirm_relocate_container().await {
1261                client.state.push_log(format!("Relocate: {err}"));
1262            }
1263        }
1264        InputAction::RelocateCancel => {
1265            client.cancel_relocate_mode();
1266        }
1267        InputAction::RelocateNudge { dx, dy } => {
1268            client.relocate_nudge(dx, dy);
1269        }
1270        InputAction::TestDamage => {
1271            if let Err(err) = client.test_damage(25.0).await {
1272                client.state.push_log(format!("Damage failed: {err}"));
1273            }
1274        }
1275        InputAction::CycleCombatTarget { reverse } => {
1276            if let Err(err) = client.cycle_combat_target(reverse).await {
1277                client.state.push_log(format!("T1 target: {err}"));
1278            }
1279        }
1280        InputAction::CycleCombatTargetT2 { reverse } => {
1281            if let Err(err) = client.cycle_combat_target_slot(2, reverse).await {
1282                client.state.push_log(format!("T2 target: {err}"));
1283            }
1284        }
1285        InputAction::AdvanceRotationT1 => {
1286            if let Err(err) = client.advance_rotation(1).await {
1287                client.state.push_log(format!("T1 step: {err}"));
1288            }
1289        }
1290        InputAction::AdvanceRotationT2 => {
1291            if let Err(err) = client.advance_rotation(2).await {
1292                client.state.push_log(format!("T2 step: {err}"));
1293            }
1294        }
1295        InputAction::ToggleAutoT1 => {
1296            if let Err(err) = client.toggle_auto_attack_slot(1).await {
1297                client.state.push_log(format!("T1 auto: {err}"));
1298            }
1299        }
1300        InputAction::ToggleAutoT2 => {
1301            if let Err(err) = client.toggle_auto_attack_slot(2).await {
1302                client.state.push_log(format!("T2 auto: {err}"));
1303            }
1304        }
1305        InputAction::ToggleLoadout => {
1306            client.state.show_rotation_editor = false;
1307            client.state.rotation_editor.reset();
1308            client.state.show_loadout_menu = !client.state.show_loadout_menu;
1309            if client.state.show_loadout_menu {
1310                client.state.loadout_focus_presets = true;
1311                client.state.loadout_hotbar_slot = client.state.loadout_hotbar_slot.clamp(1, 9);
1312                let ability_len = client.state.loadout_hotbar_choices().len();
1313                if ability_len > 0 {
1314                    client.state.loadout_ability_index =
1315                        client.state.loadout_ability_index.min(ability_len - 1);
1316                } else {
1317                    client.state.loadout_ability_index = 0;
1318                }
1319                let preset_len = client.state.rotation_presets.len();
1320                if preset_len > 0 {
1321                    client.state.loadout_menu_index =
1322                        client.state.loadout_menu_index.min(preset_len - 1);
1323                } else {
1324                    client.state.loadout_menu_index = 0;
1325                }
1326                *ctx.auto_nav = None;
1327                let _ = client.stop().await;
1328            }
1329        }
1330        InputAction::ToggleRotationEditor => {
1331            client.state.show_loadout_menu = false;
1332            if client.state.show_rotation_editor {
1333                client.state.show_rotation_editor = false;
1334                client.state.rotation_editor.reset();
1335            } else {
1336                client.state.show_rotation_editor = true;
1337                client.state.rotation_editor.reset();
1338                let max = client.state.rotation_presets.len();
1339                if max > 0 {
1340                    client.state.rotation_editor.list_index =
1341                        client.state.rotation_editor.list_index.min(max - 1);
1342                }
1343            }
1344            if client.state.show_rotation_editor {
1345                *ctx.auto_nav = None;
1346                let _ = client.stop().await;
1347            }
1348        }
1349        InputAction::LoadoutMenuUp => {
1350            if !client.state.show_loadout_menu {
1351                return;
1352            }
1353            if client.state.loadout_focus_presets {
1354                if client.state.loadout_menu_index > 0 {
1355                    client.state.loadout_menu_index -= 1;
1356                }
1357            } else if client.state.loadout_ability_index > 0 {
1358                client.state.loadout_ability_index -= 1;
1359            }
1360        }
1361        InputAction::LoadoutMenuDown => {
1362            if !client.state.show_loadout_menu {
1363                return;
1364            }
1365            if client.state.loadout_focus_presets {
1366                let max = client.state.rotation_presets.len();
1367                if max > 0 {
1368                    client.state.loadout_menu_index =
1369                        (client.state.loadout_menu_index + 1).min(max - 1);
1370                }
1371            } else {
1372                let max = client.state.loadout_hotbar_choices().len();
1373                if max > 0 {
1374                    client.state.loadout_ability_index =
1375                        (client.state.loadout_ability_index + 1).min(max - 1);
1376                }
1377            }
1378        }
1379        InputAction::LoadoutHotbarPrev => {
1380            if client.state.show_loadout_menu {
1381                let slot = client.state.loadout_hotbar_slot;
1382                client.state.loadout_hotbar_slot = if slot <= 1 { 9 } else { slot - 1 };
1383            }
1384        }
1385        InputAction::LoadoutHotbarNext => {
1386            if client.state.show_loadout_menu {
1387                let slot = client.state.loadout_hotbar_slot;
1388                client.state.loadout_hotbar_slot = if slot >= 9 { 1 } else { slot + 1 };
1389            }
1390        }
1391        InputAction::LoadoutToggleFocus => {
1392            if client.state.show_loadout_menu {
1393                client.state.loadout_focus_presets = !client.state.loadout_focus_presets;
1394            }
1395        }
1396        InputAction::LoadoutBindHotbar => {
1397            if !client.state.show_loadout_menu {
1398                return;
1399            }
1400            let slot = client.state.loadout_hotbar_slot;
1401            let binding = {
1402                let choices = client.state.loadout_hotbar_choices();
1403                choices
1404                    .get(client.state.loadout_ability_index)
1405                    .map(|c| c.binding.clone())
1406            };
1407            if let Some(binding) = binding {
1408                if let Err(err) = client.set_hotbar_slot(slot, Some(&binding)).await {
1409                    client.state.push_log(format!("Hotbar: {err}"));
1410                }
1411            } else {
1412                client.state.push_log("No ability/consumable selected to bind");
1413            }
1414        }
1415        InputAction::LoadoutClearHotbar => {
1416            if !client.state.show_loadout_menu {
1417                return;
1418            }
1419            let slot = client.state.loadout_hotbar_slot;
1420            if let Err(err) = client.set_hotbar_slot(slot, None).await {
1421                client.state.push_log(format!("Hotbar: {err}"));
1422            }
1423        }
1424        InputAction::LoadoutAssignT1 => {
1425            if !client.state.show_loadout_menu {
1426                return;
1427            }
1428            let preset = {
1429                let idx = client.state.loadout_menu_index;
1430                client.state.rotation_presets.get(idx).cloned()
1431            };
1432            if let Some(preset) = preset {
1433                let already = client
1434                    .state
1435                    .combat_slots
1436                    .iter()
1437                    .find(|s| s.slot_index == 1)
1438                    .and_then(|s| s.preset_id.as_deref())
1439                    == Some(preset.id.as_str());
1440                if already {
1441                    client.state.push_log(format!(
1442                        "T1 already uses {} (equip weapons from inventory — Weapon auto tracks mainhand)",
1443                        preset.label
1444                    ));
1445                } else if let Err(err) = client.assign_slot_preset(1, &preset.id).await {
1446                    client.state.push_log(format!("Loadout: {err}"));
1447                } else {
1448                    client
1449                        .state
1450                        .push_log(format!("T1 ← {}", preset.label));
1451                }
1452            }
1453        }
1454        InputAction::LoadoutAssignT2 => {
1455            if !client.state.show_loadout_menu {
1456                return;
1457            }
1458            let preset = {
1459                let idx = client.state.loadout_menu_index;
1460                client.state.rotation_presets.get(idx).cloned()
1461            };
1462            if let Some(preset) = preset {
1463                let already = client
1464                    .state
1465                    .combat_slots
1466                    .iter()
1467                    .find(|s| s.slot_index == 2)
1468                    .and_then(|s| s.preset_id.as_deref())
1469                    == Some(preset.id.as_str());
1470                if already {
1471                    client
1472                        .state
1473                        .push_log(format!("T2 already uses {}", preset.label));
1474                } else if let Err(err) = client.assign_slot_preset(2, &preset.id).await {
1475                    client.state.push_log(format!("Loadout: {err}"));
1476                } else {
1477                    client
1478                        .state
1479                        .push_log(format!("T2 ← {}", preset.label));
1480                }
1481            }
1482        }
1483        InputAction::CloseOverlay => {
1484            client.state.show_loadout_menu = false;
1485            client.state.show_rotation_editor = false;
1486            client.state.rotation_editor.reset();
1487        }
1488        InputAction::ClearCombatTarget => {
1489            if let Err(err) = client.clear_combat_target().await {
1490                client.state.push_log(format!("Target: {err}"));
1491            }
1492        }
1493        InputAction::ClearCombatTargetT2 => {
1494            if let Err(err) = client.clear_combat_target_slot(2).await {
1495                client.state.push_log(format!("T2 target: {err}"));
1496            }
1497        }
1498        InputAction::Dodge => {
1499            *ctx.auto_nav = None;
1500            if let Err(err) = client.dodge().await {
1501                client.state.push_log(format!("Dodge: {err}"));
1502            }
1503        }
1504        InputAction::Lunge => {
1505            *ctx.auto_nav = None;
1506            let _ = client.stop().await;
1507            if let Err(err) = client.lunge().await {
1508                client.state.push_log(format!("Lunge: {err}"));
1509            }
1510        }
1511        InputAction::DirectionalJump { forward, strafe } => {
1512            *ctx.auto_nav = None;
1513            let _ = client.stop().await;
1514            if let Err(err) = client.directional_jump(forward, strafe).await {
1515                client.state.push_log(format!("Jump: {err}"));
1516            }
1517        }
1518        InputAction::CastHotbar { slot } => {
1519            if let Err(err) = client.cast_hotbar_ability(slot).await {
1520                client.state.push_log(format!("Hotbar {slot}: {err}"));
1521            }
1522        }
1523        InputAction::ToggleBlock => {
1524            if !client.state.blocking_active {
1525                if let Err(err) = client.set_block(true).await {
1526                    client.state.push_log(format!("Block: {err}"));
1527                }
1528            }
1529        }
1530        InputAction::ToggleStats => client.toggle_stats(),
1531        InputAction::ToggleEquip => client.toggle_equip_menu(),
1532        InputAction::CycleCharacterSheetTab => client.cycle_character_sheet_tab(),
1533        InputAction::LedgerPeriodDigit(c) => client.set_ledger_period_digit(c),
1534        InputAction::ToggleInventory => client.toggle_inventory_menu(),
1535        InputAction::ToggleKeychain => client.toggle_keychain_menu(),
1536        InputAction::ToggleQuestMenu => client.toggle_quest_menu(),
1537        InputAction::ToggleWorkersMenu => {
1538            if client.state.show_workers_menu {
1539                if let Err(err) = client.close_workers_menu().await {
1540                    client.state.push_log(format!("Worker: {err}"));
1541                }
1542            } else {
1543                client.toggle_workers_menu();
1544            }
1545        }
1546        InputAction::QuestMenuUp => client.quest_menu_move(-1),
1547        InputAction::QuestMenuDown => client.quest_menu_move(1),
1548        InputAction::QuestWithdraw => client.quest_request_withdraw(),
1549        InputAction::RotationEditorBack => {
1550            let _ = client.back_on_esc();
1551        }
1552        InputAction::RotationEditorListUp
1553        | InputAction::RotationEditorListDown
1554        | InputAction::RotationEditorEdit
1555        | InputAction::RotationEditorNew
1556        | InputAction::RotationEditorDelete
1557        | InputAction::RotationEditorAddAbility
1558        | InputAction::RotationEditorRemoveAbility
1559        | InputAction::RotationEditorMoveAbilityUp
1560        | InputAction::RotationEditorMoveAbilityDown
1561        | InputAction::RotationEditorAbilityUp
1562        | InputAction::RotationEditorAbilityDown
1563        | InputAction::RotationEditorPickerUp
1564        | InputAction::RotationEditorPickerDown
1565        | InputAction::RotationEditorPickAbility
1566        | InputAction::RotationEditorRename
1567        | InputAction::RotationEditorConfirmLabel
1568        | InputAction::RotationEditorLabelBackspace
1569        | InputAction::RotationEditorLabelChar(_)
1570        | InputAction::RotationEditorSave => {
1571            handle_rotation_editor_action(client, action).await;
1572        }
1573        InputAction::None
1574        | InputAction::Quit
1575        | InputAction::ToggleHelp
1576        | InputAction::CycleHudView
1577        | InputAction::SubmitChat
1578        | InputAction::CancelChat
1579        | InputAction::ToggleSprintMode
1580        | InputAction::ToggleMapTarget
1581        | InputAction::ConfirmMapTarget
1582        | InputAction::CancelMapTarget
1583        | InputAction::MapTargetNudge { .. }
1584        | InputAction::CancelAutoNav
1585        | InputAction::StopMovement => {}
1586        InputAction::ToggleHudLog => {
1587            let hud_view = flatland_client_lib::ClientConfig::load()
1588                .hud_view
1589                .as_deref()
1590                .and_then(flatland_client_ui::HudViewMode::from_label)
1591                .unwrap_or_default();
1592            if hud_view != flatland_client_ui::HudViewMode::Normal {
1593                client.state.push_log("LOG hide/show only works in normal HUD (press .)");
1594                return;
1595            }
1596            client.state.hud_log_hidden = !client.state.hud_log_hidden;
1597            let mut cfg = flatland_client_lib::ClientConfig::load();
1598            let _ = cfg.save_hud_log_hidden(client.state.hud_log_hidden);
1599            if client.state.hud_log_hidden {
1600                client.state.push_log("LOG hidden — press ' to show (normal HUD)");
1601            } else {
1602                client.state.push_log("LOG shown — press ' to hide");
1603            }
1604        }
1605    }
1606}
1607
1608async fn handle_rotation_editor_action<S: PlayConnection>(
1609    client: &mut GameClient<S>,
1610    action: InputAction,
1611) {
1612    match action {
1613        InputAction::RotationEditorListUp => {
1614            if client.state.rotation_editor.list_index > 0 {
1615                client.state.rotation_editor.list_index -= 1;
1616            }
1617        }
1618        InputAction::RotationEditorListDown => {
1619            let max = client.state.rotation_presets.len();
1620            if max > 0 {
1621                client.state.rotation_editor.list_index =
1622                    (client.state.rotation_editor.list_index + 1).min(max - 1);
1623            }
1624        }
1625        InputAction::RotationEditorEdit => {
1626            let idx = client.state.rotation_editor.list_index;
1627            if let Some(preset) = client.state.rotation_presets.get(idx).cloned() {
1628                client.state.rotation_editor.draft = Some(preset);
1629                client.state.rotation_editor.ability_index = 0;
1630                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1631            }
1632        }
1633        InputAction::RotationEditorNew => {
1634            let preset = next_custom_preset(&client.state.rotation_presets);
1635            client.state.rotation_editor.list_index = client.state.rotation_presets.len();
1636            client.state.rotation_editor.draft = Some(preset);
1637            client.state.rotation_editor.ability_index = 0;
1638            client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1639        }
1640        InputAction::RotationEditorDelete => {
1641            let idx = client.state.rotation_editor.list_index;
1642            let preset_id = client.state.rotation_presets.get(idx).map(|p| p.id.clone());
1643            if let Some(id) = preset_id {
1644                if preset_deletable(&id) {
1645                    if let Err(err) = client.delete_rotation_preset(&id).await {
1646                        client.state.push_log(format!("Delete: {err}"));
1647                    } else {
1648                        let max = client.state.rotation_presets.len();
1649                        client.state.rotation_editor.list_index = if max == 0 {
1650                            0
1651                        } else {
1652                            client.state.rotation_editor.list_index.min(max - 1)
1653                        };
1654                    }
1655                } else {
1656                    client.state.push_log("Cannot delete built-in preset");
1657                }
1658            }
1659        }
1660        InputAction::RotationEditorBack => match client.state.rotation_editor.mode {
1661            RotationEditorMode::EditLabel => {
1662                client.state.rotation_editor.label_buffer.clear();
1663                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1664            }
1665            RotationEditorMode::PickAbility => {
1666                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1667            }
1668            RotationEditorMode::EditSequence => {
1669                client.state.rotation_editor.draft = None;
1670                client.state.rotation_editor.mode = RotationEditorMode::List;
1671            }
1672            RotationEditorMode::List => {}
1673        },
1674        InputAction::RotationEditorAddAbility => {
1675            client.state.rotation_editor.picker_index = 0;
1676            client.state.rotation_editor.mode = RotationEditorMode::PickAbility;
1677        }
1678        InputAction::RotationEditorRemoveAbility => {
1679            let idx = client.state.rotation_editor.ability_index;
1680            if let Some(draft) = &mut client.state.rotation_editor.draft {
1681                if idx < draft.abilities.len() {
1682                    draft.abilities.remove(idx);
1683                    if client.state.rotation_editor.ability_index >= draft.abilities.len()
1684                        && client.state.rotation_editor.ability_index > 0
1685                    {
1686                        client.state.rotation_editor.ability_index -= 1;
1687                    }
1688                }
1689            }
1690        }
1691        InputAction::RotationEditorMoveAbilityUp => {
1692            let i = client.state.rotation_editor.ability_index;
1693            if let Some(draft) = &mut client.state.rotation_editor.draft {
1694                if i > 0 && i < draft.abilities.len() {
1695                    draft.abilities.swap(i, i - 1);
1696                    client.state.rotation_editor.ability_index -= 1;
1697                }
1698            }
1699        }
1700        InputAction::RotationEditorMoveAbilityDown => {
1701            let i = client.state.rotation_editor.ability_index;
1702            if let Some(draft) = &mut client.state.rotation_editor.draft {
1703                if i + 1 < draft.abilities.len() {
1704                    draft.abilities.swap(i, i + 1);
1705                    client.state.rotation_editor.ability_index += 1;
1706                }
1707            }
1708        }
1709        InputAction::RotationEditorAbilityUp => {
1710            if client.state.rotation_editor.ability_index > 0 {
1711                client.state.rotation_editor.ability_index -= 1;
1712            }
1713        }
1714        InputAction::RotationEditorAbilityDown => {
1715            if let Some(draft) = &client.state.rotation_editor.draft {
1716                if !draft.abilities.is_empty() {
1717                    client.state.rotation_editor.ability_index =
1718                        (client.state.rotation_editor.ability_index + 1)
1719                            .min(draft.abilities.len() - 1);
1720                }
1721            }
1722        }
1723        InputAction::RotationEditorPickerUp => {
1724            if client.state.rotation_editor.picker_index > 0 {
1725                client.state.rotation_editor.picker_index -= 1;
1726            }
1727        }
1728        InputAction::RotationEditorPickerDown => {
1729            let choices = editor_ability_choices(
1730                &client.state.known_abilities,
1731                &client.state.weapon_ability_id,
1732            );
1733            if !choices.is_empty() {
1734                client.state.rotation_editor.picker_index =
1735                    (client.state.rotation_editor.picker_index + 1).min(choices.len() - 1);
1736            }
1737        }
1738        InputAction::RotationEditorPickAbility => {
1739            let choices = editor_ability_choices(
1740                &client.state.known_abilities,
1741                &client.state.weapon_ability_id,
1742            );
1743            let pick = client.state.rotation_editor.picker_index;
1744            if let Some(ability) = choices.get(pick) {
1745                if let Some(draft) = &mut client.state.rotation_editor.draft {
1746                    let max = client.state.max_abilities_per_rotation.max(1) as usize;
1747                    if draft.abilities.len() >= max {
1748                        client
1749                            .state
1750                            .push_log(format!("Rotation full (max {max} from INT+WIS)"));
1751                    } else {
1752                        draft.abilities.push(ability.clone());
1753                        client.state.rotation_editor.ability_index =
1754                            draft.abilities.len().saturating_sub(1);
1755                    }
1756                }
1757                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1758            }
1759        }
1760        InputAction::RotationEditorRename => {
1761            if let Some(draft) = &client.state.rotation_editor.draft {
1762                client.state.rotation_editor.label_buffer = draft.label.clone();
1763                client.state.rotation_editor.mode = RotationEditorMode::EditLabel;
1764            }
1765        }
1766        InputAction::RotationEditorConfirmLabel => {
1767            let label = client.state.rotation_editor.label_buffer.trim().to_string();
1768            if label.is_empty() {
1769                client.state.push_log("Label cannot be empty");
1770            } else if let Some(draft) = &mut client.state.rotation_editor.draft {
1771                draft.label = label;
1772                client.state.rotation_editor.label_buffer.clear();
1773                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1774            }
1775        }
1776        InputAction::RotationEditorLabelBackspace => {
1777            client.state.rotation_editor.label_buffer.pop();
1778        }
1779        InputAction::RotationEditorLabelChar(c) => {
1780            if client.state.rotation_editor.label_buffer.len() < 32 {
1781                client.state.rotation_editor.label_buffer.push(c);
1782            }
1783        }
1784        InputAction::RotationEditorSave => {
1785            let draft = match client.state.rotation_editor.draft.clone() {
1786                Some(d) => d,
1787                None => return,
1788            };
1789            if draft.abilities.is_empty() {
1790                client.state.push_log("Rotation needs at least one ability");
1791                return;
1792            }
1793            let saved_id = draft.id.clone();
1794            if let Err(err) = client.upsert_rotation_preset(draft).await {
1795                client.state.push_log(format!("Save: {err}"));
1796            } else {
1797                client.state.rotation_editor.mode = RotationEditorMode::List;
1798                client.state.rotation_editor.draft = None;
1799                if let Some(i) = client
1800                    .state
1801                    .rotation_presets
1802                    .iter()
1803                    .position(|p| p.id == saved_id)
1804                {
1805                    client.state.rotation_editor.list_index = i;
1806                }
1807            }
1808        }
1809        _ => {}
1810    }
1811}