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                if client.state.trade_ui.picking_inventory {
557                    let max = client.state.inventory_stacks.len().saturating_sub(1);
558                    client.state.trade_ui.inventory_index =
559                        client.state.trade_ui.inventory_index.min(max);
560                }
561            }
562            UiKeyCode::Up | UiKeyCode::Char('k') => {
563                if client.state.trade_ui.picking_inventory {
564                    if client.state.trade_ui.inventory_index > 0 {
565                        client.state.trade_ui.inventory_index -= 1;
566                    }
567                } else if client.state.trade_ui.select_index > 0 {
568                    client.state.trade_ui.select_index -= 1;
569                }
570            }
571            UiKeyCode::Down | UiKeyCode::Char('j') => {
572                if client.state.trade_ui.picking_inventory {
573                    let max = client.state.inventory_stacks.len().saturating_sub(1);
574                    if client.state.trade_ui.inventory_index < max {
575                        client.state.trade_ui.inventory_index += 1;
576                    }
577                }
578            }
579            UiKeyCode::PageUp => {
580                if client.state.trade_ui.picking_inventory {
581                    let n = client.state.inventory_stacks.len();
582                    client.state.trade_ui.inventory_index =
583                        flatland_client_lib::page_list_index(
584                            client.state.trade_ui.inventory_index,
585                            -1,
586                            n,
587                        );
588                }
589            }
590            UiKeyCode::PageDown => {
591                if client.state.trade_ui.picking_inventory {
592                    let n = client.state.inventory_stacks.len();
593                    client.state.trade_ui.inventory_index =
594                        flatland_client_lib::page_list_index(
595                            client.state.trade_ui.inventory_index,
596                            1,
597                            n,
598                        );
599                }
600            }
601            UiKeyCode::Enter => {
602                if client.state.trade_ui.picking_inventory {
603                    if let Err(err) = client.trade_confirm_qty_or_present().await {
604                        client.state.push_log(format!("Present failed: {err}"));
605                    }
606                }
607            }
608            _ => {}
609        }
610        return true;
611    }
612
613    if client.state.whisper_pouch_ui.open {
614        let contacts =
615            flatland_client_lib::contacts_from_stacks(&client.state.whisper_pouch_stacks);
616        match key.code {
617            UiKeyCode::Esc => client.state.whisper_pouch_ui.open = false,
618            UiKeyCode::Up | UiKeyCode::Char('k') => {
619                if client.state.whisper_pouch_ui.index > 0 {
620                    client.state.whisper_pouch_ui.index -= 1;
621                }
622            }
623            UiKeyCode::Down | UiKeyCode::Char('j') => {
624                let max = contacts.len().saturating_sub(1);
625                if client.state.whisper_pouch_ui.index < max {
626                    client.state.whisper_pouch_ui.index += 1;
627                }
628            }
629            UiKeyCode::Enter => {
630                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
631                    if !c.blank {
632                        // Resolve online peer by label match for stone chat.
633                        if let Some(peer) = client
634                            .state
635                            .entities
636                            .iter()
637                            .find(|e| e.id != client.state.entity_id && e.label == c.peer_label)
638                        {
639                            client
640                                .state
641                                .social_chat
642                                .open_stone(peer.id, &c.peer_label);
643                            client.state.whisper_pouch_ui.open = false;
644                        } else {
645                            client.state.push_log(format!(
646                                "{} is not online in this region",
647                                c.peer_label
648                            ));
649                        }
650                    }
651                }
652            }
653            UiKeyCode::Char('x') | UiKeyCode::Char('d') => {
654                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
655                    let id = c.instance_id;
656                    if let Err(err) = client.destroy_whisper_stone(id).await {
657                        client.state.push_log(format!("Destroy stone failed: {err}"));
658                    }
659                }
660            }
661            _ => {}
662        }
663        return true;
664    }
665
666    if client.state.bank_panel.is_some() {
667        match &client.state.bank_ui_mode {
668            flatland_client_lib::BankUiMode::Menu => match key.code {
669                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
670                    if let Err(err) = client.close_bank_panel().await {
671                        client.state.push_log(format!("Bank close failed: {err}"));
672                    }
673                }
674                UiKeyCode::Up | UiKeyCode::Char('k') => client.bank_menu_move(-1),
675                UiKeyCode::Down | UiKeyCode::Char('j') => client.bank_menu_move(1),
676                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
677                    if let Err(err) = client.confirm_bank_menu().await {
678                        client.state.push_log(format!("Bank action failed: {err}"));
679                    }
680                }
681                UiKeyCode::Char('d') => {
682                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::DepositAmount {
683                        input: String::new(),
684                    };
685                }
686                UiKeyCode::Char('w') => {
687                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::WithdrawAmount {
688                        input: String::new(),
689                    };
690                }
691                UiKeyCode::Char('t') => {
692                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::TransferName {
693                        input: String::new(),
694                    };
695                }
696                _ => {}
697            },
698            _ => match key.code {
699                UiKeyCode::Esc | UiKeyCode::Char('[') => client.bank_transfer_back(),
700                UiKeyCode::Enter => {
701                    if let Err(err) = client.confirm_bank_menu().await {
702                        client.state.push_log(format!("Bank action failed: {err}"));
703                    }
704                }
705                UiKeyCode::Backspace => client.bank_transfer_backspace(),
706                UiKeyCode::Char(c) => client.bank_transfer_append_char(c),
707                _ => {}
708            },
709        }
710        return true;
711    }
712
713    if client.state.storage_panel.is_some() {
714        match &client.state.storage_ui_mode {
715            flatland_client_lib::StorageUiMode::Menu => match key.code {
716                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
717                    if let Err(err) = client.close_storage_panel().await {
718                        client.state.push_log(format!("Storage close failed: {err}"));
719                    }
720                }
721                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_menu_move(-1),
722                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_menu_move(1),
723                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
724                    if let Err(err) = client.confirm_storage_menu().await {
725                        client.state.push_log(format!("Storage action failed: {err}"));
726                    }
727                }
728                UiKeyCode::Char('s') => {
729                    let opts = client.state.storage_store_options();
730                    if opts.is_empty() {
731                        client.state.push_log("Nothing loose to store.");
732                    } else {
733                        client.state.storage_ui_mode =
734                            flatland_client_lib::StorageUiMode::StorePick { index: 0 };
735                    }
736                }
737                UiKeyCode::Char('t') => {
738                    let opts = client.state.storage_vault_options();
739                    if opts.is_empty() {
740                        client.state.push_log("Vault is empty.");
741                    } else {
742                        client.state.storage_ui_mode =
743                            flatland_client_lib::StorageUiMode::TakePick { index: 0 };
744                    }
745                }
746                _ => {}
747            },
748            flatland_client_lib::StorageUiMode::StoreAmount { .. }
749            | flatland_client_lib::StorageUiMode::TakeAmount { .. }
750            | flatland_client_lib::StorageUiMode::ShipAmount { .. } => match key.code {
751                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
752                UiKeyCode::Enter => {
753                    if let Err(err) = client.confirm_storage_menu().await {
754                        client.state.push_log(format!("Storage action failed: {err}"));
755                    }
756                }
757                UiKeyCode::Backspace => client.storage_amount_backspace(),
758                UiKeyCode::Char(c) => client.storage_amount_append_char(c),
759                _ => {}
760            },
761            _ => match key.code {
762                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
763                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_pick_move(-1),
764                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_pick_move(1),
765                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
766                    if let Err(err) = client.confirm_storage_menu().await {
767                        client.state.push_log(format!("Storage action failed: {err}"));
768                    }
769                }
770                _ => {}
771            },
772        }
773        return true;
774    }
775
776    if client.state.market_panel.is_some() {
777        let filter_focused = client.state.market_filter_focused;
778        match &client.state.market_ui_mode {
779            flatland_client_lib::MarketUiMode::Browse => {
780                if filter_focused {
781                    match key.code {
782                        UiKeyCode::Esc => {
783                            let _ = client.clear_or_blur_market_filter();
784                        }
785                        UiKeyCode::Enter => {
786                            client.state.market_filter_focused = false;
787                        }
788                        UiKeyCode::Backspace => client.market_filter_backspace(),
789                        UiKeyCode::Char(c) => client.append_market_filter_char(c),
790                        _ => {}
791                    }
792                    return true;
793                }
794                match key.code {
795                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
796                    if client.state.market_buy_confirm.is_some() {
797                        client.state.market_buy_confirm = None;
798                    } else if client.clear_or_blur_market_filter() {
799                        // cleared search / category stays
800                    } else if let Err(err) = client.close_market_panel().await {
801                        client.state.push_log(format!("Market close failed: {err}"));
802                    }
803                }
804                UiKeyCode::Up | UiKeyCode::Char('k') => {
805                    if client.state.market_buy_confirm.is_none() {
806                        client.market_move_selection(-1);
807                    }
808                }
809                UiKeyCode::Down | UiKeyCode::Char('j') => {
810                    if client.state.market_buy_confirm.is_none() {
811                        client.market_move_selection(1);
812                    }
813                }
814                UiKeyCode::PageUp => {
815                    if client.state.market_buy_confirm.is_none() {
816                        client.market_page_selection(-1);
817                    }
818                }
819                UiKeyCode::PageDown => {
820                    if client.state.market_buy_confirm.is_none() {
821                        client.market_page_selection(1);
822                    }
823                }
824                UiKeyCode::Tab => {
825                    if client.state.market_buy_confirm.is_none() {
826                        client.market_cycle_category(1);
827                    }
828                }
829                UiKeyCode::Char('/') => client.focus_market_filter(),
830                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
831                    if let Err(err) = client.market_activate_selection().await {
832                        client.state.push_log(format!("Market action failed: {err}"));
833                    }
834                }
835                UiKeyCode::Char('l') => client.market_begin_list(),
836                _ => {}
837            }
838            }
839            flatland_client_lib::MarketUiMode::ListAmount { .. }
840            | flatland_client_lib::MarketUiMode::ListPrice { .. } => match key.code {
841                UiKeyCode::Esc | UiKeyCode::Char('[') => client.market_ui_back(),
842                UiKeyCode::Enter => {
843                    if let Err(err) = client.confirm_market_list_step().await {
844                        client.state.push_log(format!("Market list failed: {err}"));
845                    }
846                }
847                UiKeyCode::Backspace => client.market_list_amount_backspace(),
848                UiKeyCode::Char(c) => client.market_list_amount_append_char(c),
849                _ => {}
850            },
851            _ => {
852                if filter_focused {
853                    match key.code {
854                        UiKeyCode::Esc => {
855                            let _ = client.clear_or_blur_market_filter();
856                        }
857                        UiKeyCode::Enter => {
858                            client.state.market_filter_focused = false;
859                        }
860                        UiKeyCode::Backspace => client.market_filter_backspace(),
861                        UiKeyCode::Char(c) => client.append_market_filter_char(c),
862                        _ => {}
863                    }
864                    return true;
865                }
866                match key.code {
867                UiKeyCode::Esc | UiKeyCode::Char('[') => {
868                    if !client.clear_or_blur_market_filter() {
869                        client.market_ui_back();
870                    }
871                }
872                UiKeyCode::Up | UiKeyCode::Char('k') => client.market_list_move(-1),
873                UiKeyCode::Down | UiKeyCode::Char('j') => client.market_list_move(1),
874                UiKeyCode::PageUp => client.market_list_page(-1),
875                UiKeyCode::PageDown => client.market_list_page(1),
876                UiKeyCode::Tab => client.market_cycle_category(1),
877                UiKeyCode::Char('/') => client.focus_market_filter(),
878                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
879                    if let Err(err) = client.confirm_market_list_step().await {
880                        client.state.push_log(format!("Market list failed: {err}"));
881                    }
882                }
883                _ => {}
884            }
885            }
886        }
887        return true;
888    }
889
890    if client.state.show_shop_menu {
891        match key.code {
892            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
893                if let Err(err) = client.back_from_shop_menu().await {
894                    client.state.push_log(format!("Shop close failed: {err}"));
895                }
896            }
897            UiKeyCode::Tab => client.shop_tab_toggle(),
898            UiKeyCode::Up | UiKeyCode::Char('k') => client.shop_menu_move(-1),
899            UiKeyCode::Down | UiKeyCode::Char('j') => client.shop_menu_move(1),
900            UiKeyCode::PageUp => client.shop_menu_page(-1),
901            UiKeyCode::PageDown => client.shop_menu_page(1),
902            UiKeyCode::Char('-') => client.shop_quantity_adjust(-1),
903            UiKeyCode::Char('=') => client.shop_quantity_adjust(1),
904            UiKeyCode::Char('a') => client.shop_quantity_set_max(),
905            UiKeyCode::Enter => {
906                if let Err(err) = client.shop_confirm().await {
907                    client.state.push_log(format!("Trade failed: {err}"));
908                }
909            }
910            _ => {}
911        }
912        return true;
913    }
914
915    if client.state.show_quest_menu {
916        match key.code {
917            UiKeyCode::Esc => {
918                if client.state.quest_withdraw_confirm {
919                    client.state.quest_withdraw_confirm = false;
920                } else {
921                    client.state.show_quest_menu = false;
922                }
923            }
924            UiKeyCode::Up | UiKeyCode::Char('k') => client.quest_menu_move(-1),
925            UiKeyCode::Down | UiKeyCode::Char('j') => client.quest_menu_move(1),
926            UiKeyCode::PageUp => client.quest_menu_page(-1),
927            UiKeyCode::PageDown => client.quest_menu_page(1),
928            UiKeyCode::Char('x') => client.quest_request_withdraw(),
929            UiKeyCode::Enter => {
930                if let Err(err) = client.quest_confirm_action().await {
931                    client.state.push_log(format!("Quest: {err}"));
932                }
933            }
934            _ => {}
935        }
936        return true;
937    }
938
939    if client.state.worker_route_editor.is_some() {
940        let at_root = client.re_at_root_sheet();
941        if at_root {
942            // Root: the ordered stop list.
943            match key.code {
944                UiKeyCode::Esc => client.close_worker_route_editor(),
945                UiKeyCode::Char('s') => {
946                    if let Err(err) = client.worker_route_editor_save().await {
947                        client.state.push_log(format!("Route: {err}"));
948                    }
949                }
950                UiKeyCode::Down => {
951                    if key.modifiers.contains_control() {
952                        client.worker_route_editor_move_selected(1);
953                    } else {
954                        client.worker_route_editor_select(1);
955                    }
956                }
957                UiKeyCode::Up => {
958                    if key.modifiers.contains_control() {
959                        client.worker_route_editor_move_selected(-1);
960                    } else {
961                        client.worker_route_editor_select(-1);
962                    }
963                }
964                // Select: vim j/k. Reorder: Ctrl+j = earlier (up), Ctrl+k = later (down)
965                // — matches "k moves down" and keeps arrow+Ctrl natural.
966                UiKeyCode::Char('j') => {
967                    if key.modifiers.contains_control() {
968                        client.worker_route_editor_move_selected(-1);
969                    } else {
970                        client.worker_route_editor_select(1);
971                    }
972                }
973                UiKeyCode::Char('k') => {
974                    if key.modifiers.contains_control() {
975                        client.worker_route_editor_move_selected(1);
976                    } else {
977                        client.worker_route_editor_select(-1);
978                    }
979                }
980                UiKeyCode::Char('d') | UiKeyCode::Delete => {
981                    client.worker_route_editor_delete_selected()
982                }
983                UiKeyCode::Char('x') => client.worker_route_editor_clear_stops(),
984                UiKeyCode::Char('a') => client.re_open_add_menu(),
985                UiKeyCode::Char('l') => client.re_open_bed_picker(),
986                UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
987                UiKeyCode::Enter => client.re_edit_selected_stop(),
988                _ => {}
989            }
990        } else {
991            // Inside a setup sheet: uniform picker keys.
992            let filter_focused = client
993                .state
994                .worker_route_editor
995                .as_ref()
996                .map(|ed| ed.sheet_filter_focused)
997                .unwrap_or(false);
998            let sheet_index = client.re_sheet_index();
999
1000            if filter_focused {
1001                // Search mode: every key is filter text until Enter (no j/k/list binds).
1002                match key.code {
1003                    UiKeyCode::Esc => {
1004                        let _ = client.clear_or_blur_re_sheet_filter();
1005                    }
1006                    UiKeyCode::Enter => client.re_blur_sheet_filter_keep_text(),
1007                    UiKeyCode::Backspace => client.re_sheet_filter_backspace(),
1008                    UiKeyCode::Char(c) => client.re_append_sheet_filter_char(c),
1009                    _ => {}
1010                }
1011            } else {
1012                match key.code {
1013                    UiKeyCode::Esc => {
1014                        if !client.clear_or_blur_re_sheet_filter() {
1015                            client.re_sheet_back();
1016                        }
1017                    }
1018                    UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
1019                    UiKeyCode::PageUp => client.re_sheet_page(-1),
1020                    UiKeyCode::PageDown => client.re_sheet_page(1),
1021                    UiKeyCode::Char('/') => client.re_focus_sheet_filter(),
1022                    UiKeyCode::Char('j') | UiKeyCode::Down => client.re_sheet_move(1),
1023                    UiKeyCode::Char('k') | UiKeyCode::Up => client.re_sheet_move(-1),
1024                    UiKeyCode::Enter | UiKeyCode::Char(' ') => {
1025                        client.re_sheet_row_activate(sheet_index)
1026                    }
1027                    UiKeyCode::Char('[') | UiKeyCode::Char('-') => client.re_sheet_adjust(-1),
1028                    UiKeyCode::Char(']') | UiKeyCode::Char('=') => client.re_sheet_adjust(1),
1029                    _ => {}
1030                }
1031            }
1032        }
1033        return true;
1034    }
1035
1036    if client.state.show_plant_menu {
1037        match key.code {
1038            UiKeyCode::Esc => client.close_plant_menu(),
1039            UiKeyCode::Up | UiKeyCode::Char('k') => client.plant_menu_move(-1),
1040            UiKeyCode::Down | UiKeyCode::Char('j') => client.plant_menu_move(1),
1041            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
1042                client.plant_menu_adjust_quantity(-1)
1043            }
1044            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
1045                client.plant_menu_adjust_quantity(1)
1046            }
1047            UiKeyCode::Char('a') | UiKeyCode::Char('A') => client.plant_menu_set_quantity_max(),
1048            UiKeyCode::Enter | UiKeyCode::Char('f') | UiKeyCode::Char('F') => {
1049                if let Err(err) = client.confirm_plant_menu().await {
1050                    client.state.push_log(format!("Plant: {err}"));
1051                }
1052            }
1053            _ => {}
1054        }
1055        return true;
1056    }
1057
1058    if client.state.show_farm_access {
1059        match key.code {
1060            UiKeyCode::Esc => client.close_farm_access_panel(),
1061            UiKeyCode::Up | UiKeyCode::Char('k') => client.farm_access_move(-1),
1062            UiKeyCode::Down | UiKeyCode::Char('j') => client.farm_access_move(1),
1063            UiKeyCode::Enter | UiKeyCode::Char(' ') => {
1064                if let Err(err) = client.farm_access_activate().await {
1065                    client.state.push_log(format!("Farm access: {err}"));
1066                }
1067            }
1068            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
1069                if let Err(err) = client.farm_access_adjust_discount(-100).await {
1070                    client.state.push_log(format!("Farm access: {err}"));
1071                }
1072            }
1073            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
1074                if let Err(err) = client.farm_access_adjust_discount(100).await {
1075                    client.state.push_log(format!("Farm access: {err}"));
1076                }
1077            }
1078            _ => {}
1079        }
1080        return true;
1081    }
1082
1083    if client.state.show_worker_give_picker {
1084        match key.code {
1085            UiKeyCode::Esc => client.close_worker_give_picker(),
1086            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_picker_move(-1),
1087            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_picker_move(1),
1088            UiKeyCode::Enter => {
1089                if let Err(err) = client.confirm_worker_give_picker().await {
1090                    client.state.push_log(format!("Give: {err}"));
1091                }
1092            }
1093            _ => {}
1094        }
1095        return true;
1096    }
1097
1098    if client.state.show_worker_give_target_picker {
1099        match key.code {
1100            UiKeyCode::Esc => client.close_worker_give_target_picker(),
1101            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_target_picker_move(-1),
1102            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_target_picker_move(1),
1103            UiKeyCode::Enter => {
1104                if let Err(err) = client.confirm_worker_give_target_picker().await {
1105                    client.state.push_log(format!("Give: {err}"));
1106                }
1107            }
1108            _ => {}
1109        }
1110        return true;
1111    }
1112
1113    if client.state.show_worker_take_picker {
1114        match key.code {
1115            UiKeyCode::Esc => client.close_worker_take_picker(),
1116            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_take_picker_move(-1),
1117            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_take_picker_move(1),
1118            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
1119                client.worker_take_picker_adjust_quantity(-1)
1120            }
1121            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
1122                client.worker_take_picker_adjust_quantity(1)
1123            }
1124            UiKeyCode::Char('a') => client.worker_take_picker_set_quantity_max(),
1125            UiKeyCode::Enter => {
1126                if let Err(err) = client.confirm_worker_take_picker().await {
1127                    client.state.push_log(format!("Take: {err}"));
1128                }
1129            }
1130            _ => {}
1131        }
1132        return true;
1133    }
1134
1135    if client.state.show_worker_teach_picker {
1136        match key.code {
1137            UiKeyCode::Esc => client.close_worker_teach_picker(),
1138            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_teach_picker_move(-1),
1139            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_teach_picker_move(1),
1140            UiKeyCode::Enter => {
1141                if let Err(err) = client.confirm_worker_teach_picker().await {
1142                    client.state.push_log(format!("Teach: {err}"));
1143                }
1144            }
1145            _ => {}
1146        }
1147        return true;
1148    }
1149
1150    if client.state.show_workers_menu {
1151        match key.code {
1152            UiKeyCode::Esc => {
1153                if let Err(err) = client.close_workers_menu().await {
1154                    client.state.push_log(format!("Worker: {err}"));
1155                }
1156            }
1157            UiKeyCode::Up | UiKeyCode::Char('k') => client.workers_menu_move(-1),
1158            UiKeyCode::Down | UiKeyCode::Char('j') => client.workers_menu_move(1),
1159            UiKeyCode::PageUp => client.workers_menu_page(-1),
1160            UiKeyCode::PageDown => client.workers_menu_page(1),
1161            UiKeyCode::Char('d') => {
1162                if let Err(err) = client.workers_dismiss_selected().await {
1163                    client.state.push_log(format!("Worker: {err}"));
1164                }
1165            }
1166            UiKeyCode::Char('r') => {
1167                if let Err(err) = client.hire_worker_laborer().await {
1168                    client.state.push_log(format!("Hire: {err}"));
1169                }
1170            }
1171            UiKeyCode::Char('e') => {
1172                if let Err(err) = client.open_worker_route_editor_for_selected() {
1173                    client.state.push_log(format!("Route: {err}"));
1174                }
1175            }
1176            UiKeyCode::Char('n') => {
1177                if let Err(err) = client.open_worker_rename() {
1178                    client.state.push_log(format!("Rename: {err}"));
1179                }
1180            }
1181            UiKeyCode::Char('g') => {
1182                if let Err(err) = client.open_worker_give_picker() {
1183                    client.state.push_log(format!("Give: {err}"));
1184                }
1185            }
1186            UiKeyCode::Char('i') => {
1187                if let Err(err) = client.open_worker_take_picker() {
1188                    client.state.push_log(format!("Take: {err}"));
1189                }
1190            }
1191            UiKeyCode::Char('t') => {
1192                if let Err(err) = client.open_worker_teach_picker() {
1193                    client.state.push_log(format!("Teach: {err}"));
1194                }
1195            }
1196            UiKeyCode::Char('c') => client.toggle_workers_menu_compact(),
1197            UiKeyCode::Enter => {
1198                if let Err(err) = client.workers_confirm_action().await {
1199                    client.state.push_log(format!("Worker: {err}"));
1200                }
1201            }
1202            _ => {}
1203        }
1204        return true;
1205    }
1206
1207    false
1208}
1209
1210pub async fn dispatch_action<S: PlayConnection>(
1211    client: &mut GameClient<S>,
1212    _keys: &ClientKeyBindings,
1213    action: InputAction,
1214    ctx: &mut ActionCtx<'_>,
1215) {
1216    match action {
1217        InputAction::StartChat { whisper } => {
1218            if whisper {
1219                // Stone contacts: pick inside CHAT column (no separate popup).
1220                client.state.whisper_pouch_ui.open = false;
1221                client.state.social_chat.picking_stone = true;
1222                client.state.social_chat.stone_pick_index = 0;
1223                client.state.social_chat.input_focused = false;
1224                client.state.social_chat.push_system(
1225                    "Whisper stones — ↑↓ select · Enter · Esc cancel",
1226                );
1227            } else {
1228                client.state.social_chat.focus_nearby();
1229            }
1230        }
1231        InputAction::Harvest => {
1232            if let Err(err) = client.harvest_nearest().await {
1233                client.state.push_log(format!("Harvest failed: {err}"));
1234            }
1235        }
1236        InputAction::Pickup => {
1237            if client.pickup_nearest().await.is_err() {
1238                if let Err(err) = client.pickup_nearest_container().await {
1239                    client.state.push_log(format!("Pickup: {err}"));
1240                }
1241            }
1242        }
1243        InputAction::Craft => client.open_craft_menu(),
1244        InputAction::Interact | InputAction::UseWorld => {
1245            if let Err(err) = client.use_nearest().await {
1246                client.state.push_log(format!("Use: {err}"));
1247            }
1248        }
1249        InputAction::ClaimPlotBegin => {
1250            if let Err(err) = client.try_begin_claim_mode().await {
1251                client.state.push_log(format!("Claim: {err}"));
1252            }
1253        }
1254        InputAction::FarmCultivate => {
1255            if let Err(err) = client.farm_cultivate_underfoot().await {
1256                client.state.push_log(format!("Cultivate: {err}"));
1257            }
1258        }
1259        InputAction::FarmPlant => {
1260            if let Err(err) = client.farm_plant_underfoot().await {
1261                client.state.push_log(format!("Plant: {err}"));
1262            }
1263        }
1264        InputAction::ClaimConfirm => {
1265            if let Err(err) = client.confirm_buy_plot().await {
1266                client.state.push_log(format!("Claim: {err}"));
1267            }
1268        }
1269        InputAction::ClaimCancel => {
1270            client.cancel_claim_mode();
1271        }
1272        InputAction::ClaimNudge { dw, dh } => {
1273            client.claim_nudge(dw, dh);
1274        }
1275        InputAction::ClaimMoveNudge { dx, dy } => {
1276            client.claim_move_nudge(dx, dy);
1277        }
1278        InputAction::ClaimPreset { w, h } => {
1279            client.claim_set_preset(w, h);
1280        }
1281        InputAction::RelocateBeginNearest => {
1282            if let Err(err) = client.try_begin_relocate_nearest() {
1283                client.state.push_log(format!("Relocate: {err}"));
1284            }
1285        }
1286        InputAction::RelocateConfirm => {
1287            if let Err(err) = client.confirm_relocate_container().await {
1288                client.state.push_log(format!("Relocate: {err}"));
1289            }
1290        }
1291        InputAction::RelocateCancel => {
1292            client.cancel_relocate_mode();
1293        }
1294        InputAction::RelocateNudge { dx, dy } => {
1295            client.relocate_nudge(dx, dy);
1296        }
1297        InputAction::TestDamage => {
1298            if let Err(err) = client.test_damage(25.0).await {
1299                client.state.push_log(format!("Damage failed: {err}"));
1300            }
1301        }
1302        InputAction::CycleCombatTarget { reverse } => {
1303            if let Err(err) = client.cycle_combat_target(reverse).await {
1304                client.state.push_log(format!("T1 target: {err}"));
1305            }
1306        }
1307        InputAction::CycleCombatTargetT2 { reverse } => {
1308            if let Err(err) = client.cycle_combat_target_slot(2, reverse).await {
1309                client.state.push_log(format!("T2 target: {err}"));
1310            }
1311        }
1312        InputAction::AdvanceRotationT1 => {
1313            if let Err(err) = client.advance_rotation(1).await {
1314                client.state.push_log(format!("T1 step: {err}"));
1315            }
1316        }
1317        InputAction::AdvanceRotationT2 => {
1318            if let Err(err) = client.advance_rotation(2).await {
1319                client.state.push_log(format!("T2 step: {err}"));
1320            }
1321        }
1322        InputAction::ToggleAutoT1 => {
1323            if let Err(err) = client.toggle_auto_attack_slot(1).await {
1324                client.state.push_log(format!("T1 auto: {err}"));
1325            }
1326        }
1327        InputAction::ToggleAutoT2 => {
1328            if let Err(err) = client.toggle_auto_attack_slot(2).await {
1329                client.state.push_log(format!("T2 auto: {err}"));
1330            }
1331        }
1332        InputAction::ToggleLoadout => {
1333            client.state.show_rotation_editor = false;
1334            client.state.rotation_editor.reset();
1335            client.state.show_loadout_menu = !client.state.show_loadout_menu;
1336            if client.state.show_loadout_menu {
1337                client.state.loadout_focus_presets = true;
1338                client.state.loadout_hotbar_slot = client.state.loadout_hotbar_slot.clamp(1, 9);
1339                let ability_len = client.state.loadout_hotbar_choices().len();
1340                if ability_len > 0 {
1341                    client.state.loadout_ability_index =
1342                        client.state.loadout_ability_index.min(ability_len - 1);
1343                } else {
1344                    client.state.loadout_ability_index = 0;
1345                }
1346                let preset_len = client.state.rotation_presets.len();
1347                if preset_len > 0 {
1348                    client.state.loadout_menu_index =
1349                        client.state.loadout_menu_index.min(preset_len - 1);
1350                } else {
1351                    client.state.loadout_menu_index = 0;
1352                }
1353                *ctx.auto_nav = None;
1354                let _ = client.stop().await;
1355            }
1356        }
1357        InputAction::ToggleRotationEditor => {
1358            client.state.show_loadout_menu = false;
1359            if client.state.show_rotation_editor {
1360                client.state.show_rotation_editor = false;
1361                client.state.rotation_editor.reset();
1362            } else {
1363                client.state.show_rotation_editor = true;
1364                client.state.rotation_editor.reset();
1365                let max = client.state.rotation_presets.len();
1366                if max > 0 {
1367                    client.state.rotation_editor.list_index =
1368                        client.state.rotation_editor.list_index.min(max - 1);
1369                }
1370            }
1371            if client.state.show_rotation_editor {
1372                *ctx.auto_nav = None;
1373                let _ = client.stop().await;
1374            }
1375        }
1376        InputAction::LoadoutMenuUp => {
1377            if !client.state.show_loadout_menu {
1378                return;
1379            }
1380            if client.state.loadout_focus_presets {
1381                if client.state.loadout_menu_index > 0 {
1382                    client.state.loadout_menu_index -= 1;
1383                }
1384            } else if client.state.loadout_ability_index > 0 {
1385                client.state.loadout_ability_index -= 1;
1386            }
1387        }
1388        InputAction::LoadoutMenuDown => {
1389            if !client.state.show_loadout_menu {
1390                return;
1391            }
1392            if client.state.loadout_focus_presets {
1393                let max = client.state.rotation_presets.len();
1394                if max > 0 {
1395                    client.state.loadout_menu_index =
1396                        (client.state.loadout_menu_index + 1).min(max - 1);
1397                }
1398            } else {
1399                let max = client.state.loadout_hotbar_choices().len();
1400                if max > 0 {
1401                    client.state.loadout_ability_index =
1402                        (client.state.loadout_ability_index + 1).min(max - 1);
1403                }
1404            }
1405        }
1406        InputAction::LoadoutHotbarPrev => {
1407            if client.state.show_loadout_menu {
1408                let slot = client.state.loadout_hotbar_slot;
1409                client.state.loadout_hotbar_slot = if slot <= 1 { 9 } else { slot - 1 };
1410            }
1411        }
1412        InputAction::LoadoutHotbarNext => {
1413            if client.state.show_loadout_menu {
1414                let slot = client.state.loadout_hotbar_slot;
1415                client.state.loadout_hotbar_slot = if slot >= 9 { 1 } else { slot + 1 };
1416            }
1417        }
1418        InputAction::LoadoutToggleFocus => {
1419            if client.state.show_loadout_menu {
1420                client.state.loadout_focus_presets = !client.state.loadout_focus_presets;
1421            }
1422        }
1423        InputAction::LoadoutBindHotbar => {
1424            if !client.state.show_loadout_menu {
1425                return;
1426            }
1427            let slot = client.state.loadout_hotbar_slot;
1428            let binding = {
1429                let choices = client.state.loadout_hotbar_choices();
1430                choices
1431                    .get(client.state.loadout_ability_index)
1432                    .map(|c| c.binding.clone())
1433            };
1434            if let Some(binding) = binding {
1435                if let Err(err) = client.set_hotbar_slot(slot, Some(&binding)).await {
1436                    client.state.push_log(format!("Hotbar: {err}"));
1437                }
1438            } else {
1439                client.state.push_log("No ability/consumable selected to bind");
1440            }
1441        }
1442        InputAction::LoadoutClearHotbar => {
1443            if !client.state.show_loadout_menu {
1444                return;
1445            }
1446            let slot = client.state.loadout_hotbar_slot;
1447            if let Err(err) = client.set_hotbar_slot(slot, None).await {
1448                client.state.push_log(format!("Hotbar: {err}"));
1449            }
1450        }
1451        InputAction::LoadoutAssignT1 => {
1452            if !client.state.show_loadout_menu {
1453                return;
1454            }
1455            let preset = {
1456                let idx = client.state.loadout_menu_index;
1457                client.state.rotation_presets.get(idx).cloned()
1458            };
1459            if let Some(preset) = preset {
1460                let already = client
1461                    .state
1462                    .combat_slots
1463                    .iter()
1464                    .find(|s| s.slot_index == 1)
1465                    .and_then(|s| s.preset_id.as_deref())
1466                    == Some(preset.id.as_str());
1467                if already {
1468                    client.state.push_log(format!(
1469                        "T1 already uses {} (equip weapons from inventory — Weapon auto tracks mainhand)",
1470                        preset.label
1471                    ));
1472                } else if let Err(err) = client.assign_slot_preset(1, &preset.id).await {
1473                    client.state.push_log(format!("Loadout: {err}"));
1474                } else {
1475                    client
1476                        .state
1477                        .push_log(format!("T1 ← {}", preset.label));
1478                }
1479            }
1480        }
1481        InputAction::LoadoutAssignT2 => {
1482            if !client.state.show_loadout_menu {
1483                return;
1484            }
1485            let preset = {
1486                let idx = client.state.loadout_menu_index;
1487                client.state.rotation_presets.get(idx).cloned()
1488            };
1489            if let Some(preset) = preset {
1490                let already = client
1491                    .state
1492                    .combat_slots
1493                    .iter()
1494                    .find(|s| s.slot_index == 2)
1495                    .and_then(|s| s.preset_id.as_deref())
1496                    == Some(preset.id.as_str());
1497                if already {
1498                    client
1499                        .state
1500                        .push_log(format!("T2 already uses {}", preset.label));
1501                } else if let Err(err) = client.assign_slot_preset(2, &preset.id).await {
1502                    client.state.push_log(format!("Loadout: {err}"));
1503                } else {
1504                    client
1505                        .state
1506                        .push_log(format!("T2 ← {}", preset.label));
1507                }
1508            }
1509        }
1510        InputAction::CloseOverlay => {
1511            client.state.show_loadout_menu = false;
1512            client.state.show_rotation_editor = false;
1513            client.state.rotation_editor.reset();
1514        }
1515        InputAction::ClearCombatTarget => {
1516            if let Err(err) = client.clear_combat_target().await {
1517                client.state.push_log(format!("Target: {err}"));
1518            }
1519        }
1520        InputAction::ClearCombatTargetT2 => {
1521            if let Err(err) = client.clear_combat_target_slot(2).await {
1522                client.state.push_log(format!("T2 target: {err}"));
1523            }
1524        }
1525        InputAction::Dodge => {
1526            *ctx.auto_nav = None;
1527            if let Err(err) = client.dodge().await {
1528                client.state.push_log(format!("Dodge: {err}"));
1529            }
1530        }
1531        InputAction::Lunge => {
1532            *ctx.auto_nav = None;
1533            let _ = client.stop().await;
1534            if let Err(err) = client.lunge().await {
1535                client.state.push_log(format!("Lunge: {err}"));
1536            }
1537        }
1538        InputAction::DirectionalJump { forward, strafe } => {
1539            *ctx.auto_nav = None;
1540            let _ = client.stop().await;
1541            if let Err(err) = client.directional_jump(forward, strafe).await {
1542                client.state.push_log(format!("Jump: {err}"));
1543            }
1544        }
1545        InputAction::CastHotbar { slot } => {
1546            if let Err(err) = client.cast_hotbar_ability(slot).await {
1547                client.state.push_log(format!("Hotbar {slot}: {err}"));
1548            }
1549        }
1550        InputAction::ToggleBlock => {
1551            if !client.state.blocking_active {
1552                if let Err(err) = client.set_block(true).await {
1553                    client.state.push_log(format!("Block: {err}"));
1554                }
1555            }
1556        }
1557        InputAction::ToggleStats => client.toggle_stats(),
1558        InputAction::ToggleEquip => client.toggle_equip_menu(),
1559        InputAction::CycleCharacterSheetTab => client.cycle_character_sheet_tab(),
1560        InputAction::LedgerPeriodDigit(c) => client.set_ledger_period_digit(c),
1561        InputAction::ToggleInventory => client.toggle_inventory_menu(),
1562        InputAction::ToggleKeychain => client.toggle_keychain_menu(),
1563        InputAction::ToggleQuestMenu => client.toggle_quest_menu(),
1564        InputAction::ToggleWorkersMenu => {
1565            if client.state.show_workers_menu {
1566                if let Err(err) = client.close_workers_menu().await {
1567                    client.state.push_log(format!("Worker: {err}"));
1568                }
1569            } else {
1570                client.toggle_workers_menu();
1571            }
1572        }
1573        InputAction::QuestMenuUp => client.quest_menu_move(-1),
1574        InputAction::QuestMenuDown => client.quest_menu_move(1),
1575        InputAction::QuestWithdraw => client.quest_request_withdraw(),
1576        InputAction::RotationEditorBack => {
1577            let _ = client.back_on_esc();
1578        }
1579        InputAction::RotationEditorListUp
1580        | InputAction::RotationEditorListDown
1581        | InputAction::RotationEditorEdit
1582        | InputAction::RotationEditorNew
1583        | InputAction::RotationEditorDelete
1584        | InputAction::RotationEditorAddAbility
1585        | InputAction::RotationEditorRemoveAbility
1586        | InputAction::RotationEditorMoveAbilityUp
1587        | InputAction::RotationEditorMoveAbilityDown
1588        | InputAction::RotationEditorAbilityUp
1589        | InputAction::RotationEditorAbilityDown
1590        | InputAction::RotationEditorPickerUp
1591        | InputAction::RotationEditorPickerDown
1592        | InputAction::RotationEditorPickAbility
1593        | InputAction::RotationEditorRename
1594        | InputAction::RotationEditorConfirmLabel
1595        | InputAction::RotationEditorLabelBackspace
1596        | InputAction::RotationEditorLabelChar(_)
1597        | InputAction::RotationEditorSave => {
1598            handle_rotation_editor_action(client, action).await;
1599        }
1600        InputAction::None
1601        | InputAction::Quit
1602        | InputAction::ToggleHelp
1603        | InputAction::CycleHudView
1604        | InputAction::SubmitChat
1605        | InputAction::CancelChat
1606        | InputAction::ToggleSprintMode
1607        | InputAction::ToggleMapTarget
1608        | InputAction::ConfirmMapTarget
1609        | InputAction::CancelMapTarget
1610        | InputAction::MapTargetNudge { .. }
1611        | InputAction::CancelAutoNav
1612        | InputAction::StopMovement => {}
1613        InputAction::ToggleHudLog => {
1614            let hud_view = flatland_client_lib::ClientConfig::load()
1615                .hud_view
1616                .as_deref()
1617                .and_then(flatland_client_ui::HudViewMode::from_label)
1618                .unwrap_or_default();
1619            if hud_view != flatland_client_ui::HudViewMode::Normal {
1620                client.state.push_log("LOG hide/show only works in normal HUD (press .)");
1621                return;
1622            }
1623            client.state.hud_log_hidden = !client.state.hud_log_hidden;
1624            let mut cfg = flatland_client_lib::ClientConfig::load();
1625            let _ = cfg.save_hud_log_hidden(client.state.hud_log_hidden);
1626            if client.state.hud_log_hidden {
1627                client.state.push_log("LOG hidden — press ' to show (normal HUD)");
1628            } else {
1629                client.state.push_log("LOG shown — press ' to hide");
1630            }
1631        }
1632    }
1633}
1634
1635async fn handle_rotation_editor_action<S: PlayConnection>(
1636    client: &mut GameClient<S>,
1637    action: InputAction,
1638) {
1639    match action {
1640        InputAction::RotationEditorListUp => {
1641            if client.state.rotation_editor.list_index > 0 {
1642                client.state.rotation_editor.list_index -= 1;
1643            }
1644        }
1645        InputAction::RotationEditorListDown => {
1646            let max = client.state.rotation_presets.len();
1647            if max > 0 {
1648                client.state.rotation_editor.list_index =
1649                    (client.state.rotation_editor.list_index + 1).min(max - 1);
1650            }
1651        }
1652        InputAction::RotationEditorEdit => {
1653            let idx = client.state.rotation_editor.list_index;
1654            if let Some(preset) = client.state.rotation_presets.get(idx).cloned() {
1655                client.state.rotation_editor.draft = Some(preset);
1656                client.state.rotation_editor.ability_index = 0;
1657                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1658            }
1659        }
1660        InputAction::RotationEditorNew => {
1661            let preset = next_custom_preset(&client.state.rotation_presets);
1662            client.state.rotation_editor.list_index = client.state.rotation_presets.len();
1663            client.state.rotation_editor.draft = Some(preset);
1664            client.state.rotation_editor.ability_index = 0;
1665            client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1666        }
1667        InputAction::RotationEditorDelete => {
1668            let idx = client.state.rotation_editor.list_index;
1669            let preset_id = client.state.rotation_presets.get(idx).map(|p| p.id.clone());
1670            if let Some(id) = preset_id {
1671                if preset_deletable(&id) {
1672                    if let Err(err) = client.delete_rotation_preset(&id).await {
1673                        client.state.push_log(format!("Delete: {err}"));
1674                    } else {
1675                        let max = client.state.rotation_presets.len();
1676                        client.state.rotation_editor.list_index = if max == 0 {
1677                            0
1678                        } else {
1679                            client.state.rotation_editor.list_index.min(max - 1)
1680                        };
1681                    }
1682                } else {
1683                    client.state.push_log("Cannot delete built-in preset");
1684                }
1685            }
1686        }
1687        InputAction::RotationEditorBack => match client.state.rotation_editor.mode {
1688            RotationEditorMode::EditLabel => {
1689                client.state.rotation_editor.label_buffer.clear();
1690                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1691            }
1692            RotationEditorMode::PickAbility => {
1693                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1694            }
1695            RotationEditorMode::EditSequence => {
1696                client.state.rotation_editor.draft = None;
1697                client.state.rotation_editor.mode = RotationEditorMode::List;
1698            }
1699            RotationEditorMode::List => {}
1700        },
1701        InputAction::RotationEditorAddAbility => {
1702            client.state.rotation_editor.picker_index = 0;
1703            client.state.rotation_editor.mode = RotationEditorMode::PickAbility;
1704        }
1705        InputAction::RotationEditorRemoveAbility => {
1706            let idx = client.state.rotation_editor.ability_index;
1707            if let Some(draft) = &mut client.state.rotation_editor.draft {
1708                if idx < draft.abilities.len() {
1709                    draft.abilities.remove(idx);
1710                    if client.state.rotation_editor.ability_index >= draft.abilities.len()
1711                        && client.state.rotation_editor.ability_index > 0
1712                    {
1713                        client.state.rotation_editor.ability_index -= 1;
1714                    }
1715                }
1716            }
1717        }
1718        InputAction::RotationEditorMoveAbilityUp => {
1719            let i = client.state.rotation_editor.ability_index;
1720            if let Some(draft) = &mut client.state.rotation_editor.draft {
1721                if i > 0 && i < draft.abilities.len() {
1722                    draft.abilities.swap(i, i - 1);
1723                    client.state.rotation_editor.ability_index -= 1;
1724                }
1725            }
1726        }
1727        InputAction::RotationEditorMoveAbilityDown => {
1728            let i = client.state.rotation_editor.ability_index;
1729            if let Some(draft) = &mut client.state.rotation_editor.draft {
1730                if i + 1 < draft.abilities.len() {
1731                    draft.abilities.swap(i, i + 1);
1732                    client.state.rotation_editor.ability_index += 1;
1733                }
1734            }
1735        }
1736        InputAction::RotationEditorAbilityUp => {
1737            if client.state.rotation_editor.ability_index > 0 {
1738                client.state.rotation_editor.ability_index -= 1;
1739            }
1740        }
1741        InputAction::RotationEditorAbilityDown => {
1742            if let Some(draft) = &client.state.rotation_editor.draft {
1743                if !draft.abilities.is_empty() {
1744                    client.state.rotation_editor.ability_index =
1745                        (client.state.rotation_editor.ability_index + 1)
1746                            .min(draft.abilities.len() - 1);
1747                }
1748            }
1749        }
1750        InputAction::RotationEditorPickerUp => {
1751            if client.state.rotation_editor.picker_index > 0 {
1752                client.state.rotation_editor.picker_index -= 1;
1753            }
1754        }
1755        InputAction::RotationEditorPickerDown => {
1756            let choices = editor_ability_choices(
1757                &client.state.known_abilities,
1758                &client.state.weapon_ability_id,
1759            );
1760            if !choices.is_empty() {
1761                client.state.rotation_editor.picker_index =
1762                    (client.state.rotation_editor.picker_index + 1).min(choices.len() - 1);
1763            }
1764        }
1765        InputAction::RotationEditorPickAbility => {
1766            let choices = editor_ability_choices(
1767                &client.state.known_abilities,
1768                &client.state.weapon_ability_id,
1769            );
1770            let pick = client.state.rotation_editor.picker_index;
1771            if let Some(ability) = choices.get(pick) {
1772                if let Some(draft) = &mut client.state.rotation_editor.draft {
1773                    let max = client.state.max_abilities_per_rotation.max(1) as usize;
1774                    if draft.abilities.len() >= max {
1775                        client
1776                            .state
1777                            .push_log(format!("Rotation full (max {max} from INT+WIS)"));
1778                    } else {
1779                        draft.abilities.push(ability.clone());
1780                        client.state.rotation_editor.ability_index =
1781                            draft.abilities.len().saturating_sub(1);
1782                    }
1783                }
1784                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1785            }
1786        }
1787        InputAction::RotationEditorRename => {
1788            if let Some(draft) = &client.state.rotation_editor.draft {
1789                client.state.rotation_editor.label_buffer = draft.label.clone();
1790                client.state.rotation_editor.mode = RotationEditorMode::EditLabel;
1791            }
1792        }
1793        InputAction::RotationEditorConfirmLabel => {
1794            let label = client.state.rotation_editor.label_buffer.trim().to_string();
1795            if label.is_empty() {
1796                client.state.push_log("Label cannot be empty");
1797            } else if let Some(draft) = &mut client.state.rotation_editor.draft {
1798                draft.label = label;
1799                client.state.rotation_editor.label_buffer.clear();
1800                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1801            }
1802        }
1803        InputAction::RotationEditorLabelBackspace => {
1804            client.state.rotation_editor.label_buffer.pop();
1805        }
1806        InputAction::RotationEditorLabelChar(c) => {
1807            if client.state.rotation_editor.label_buffer.len() < 32 {
1808                client.state.rotation_editor.label_buffer.push(c);
1809            }
1810        }
1811        InputAction::RotationEditorSave => {
1812            let draft = match client.state.rotation_editor.draft.clone() {
1813                Some(d) => d,
1814                None => return,
1815            };
1816            if draft.abilities.is_empty() {
1817                client.state.push_log("Rotation needs at least one ability");
1818                return;
1819            }
1820            let saved_id = draft.id.clone();
1821            if let Err(err) = client.upsert_rotation_preset(draft).await {
1822                client.state.push_log(format!("Save: {err}"));
1823            } else {
1824                client.state.rotation_editor.mode = RotationEditorMode::List;
1825                client.state.rotation_editor.draft = None;
1826                if let Some(i) = client
1827                    .state
1828                    .rotation_presets
1829                    .iter()
1830                    .position(|p| p.id == saved_id)
1831                {
1832                    client.state.rotation_editor.list_index = i;
1833                }
1834            }
1835        }
1836        _ => {}
1837    }
1838}