flatland3 0.2.0

Flatland3 terminal play client and account CLI
use std::io::{self, IsTerminal};
use std::net::SocketAddr;
use std::time::{Duration, Instant};

use clap::Parser;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use flatland_client_lib::{connect, default_server_addr, ConnectOptions, GameClient};
use flatland_protocol::ChatChannel;
use flatland_tui::{draw, InputAction, MovementState, MOVEMENT_IDLE_TIMEOUT};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::sync::mpsc;
use tracing::{error, info, warn};

#[derive(Debug, Parser)]
#[command(about = "Ratatui play client — connect to flatland-server")]
pub struct PlayArgs {
    /// Character name (created on first login if missing)
    #[arg(short, long, default_value = "traveler")]
    pub name: String,

    /// Game server address (flatland-server TCP)
    #[arg(long, default_value_t = default_server_addr())]
    pub server: SocketAddr,
}

pub async fn run_with_options(opts: ConnectOptions) -> anyhow::Result<()> {
    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
        anyhow::bail!(
            "play requires an interactive terminal (stdin/stdout TTY).\n\
             Run from kitty/iTerm/Terminal.app, not a pipe or background job.\n\
             To verify the server: cargo test -p flatland-client-lib --test connect_stress -- --nocapture"
        );
    }

    let session = connect(opts).await?;

    let mut client = GameClient::new(session);
    client.wait_until_ready().await?;

    info!(
        entity_id = client.entity_id(),
        "connected — WASD move, e harvest, c craft menu, f interact, i stats, - hurt, t chat, g whisper, q quit"
    );

    crate::terminal::enter()?;
    let _terminal = crate::terminal::Guard;

    // Some terminals emit focus/resize noise when entering the alt screen.
    while event::poll(Duration::from_millis(0)).unwrap_or(false) {
        let _ = event::read();
    }

    let (key_tx, mut key_rx) = mpsc::unbounded_channel::<Event>();
    std::thread::spawn(move || {
        loop {
            if event::poll(Duration::from_millis(50)).unwrap_or(false) {
                if let Ok(ev) = event::read() {
                    if key_tx.send(ev).is_err() {
                        break;
                    }
                }
            }
        }
    });

    let backend = CrosstermBackend::new(io::stdout());
    let mut ratatui = Terminal::new(backend)?;

    let mut movement_state = MovementState::default();
    let mut tick_interval = tokio::time::interval(Duration::from_millis(33));
    let mut intent_interval = tokio::time::interval(Duration::from_millis(100));
    intent_interval.tick().await;
    let mut chat_buffer = String::new();
    let mut chat_whisper = false;
    let mut chat_mode = false;
    let session_started = Instant::now();
    let mut quit_armed = false;
    let mut user_quit = false;
    let mut was_moving = false;

    loop {
        client.drain_events();
        if !client.state.connected && !quit_armed && !user_quit {
            quit_armed = true;
            client.state.push_log("Connection lost — press q to exit");
            if let Some(reason) = client
                .state
                .logs
                .iter()
                .rev()
                .find(|l| l.starts_with("Disconnected"))
            {
                error!("{reason}");
            } else {
                error!("connection lost");
            }
        }

        let movement = movement_state.current();
        let movement_label = if quit_armed {
            "offline"
        } else {
            movement.label()
        };
        let chat_line = if chat_mode {
            let prefix = if chat_whisper { "whisper> " } else { "say> " };
            format!("{prefix}{chat_buffer}")
        } else {
            String::new()
        };
        ratatui.draw(|frame| draw(frame, &client.state, movement_label, &chat_line))?;

        tokio::select! {
            _ = tick_interval.tick() => {
                client.drain_events();
            }
            _ = intent_interval.tick() => {
                movement_state.expire_idle(MOVEMENT_IDLE_TIMEOUT);
                if !chat_mode
                    && !client.state.show_craft_menu
                    && client.state.connected
                    && client.state.is_alive()
                {
                    let (forward, strafe) = movement.components();
                    let moving = forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON;
                    if moving {
                        if let Err(err) = client.move_by(forward, strafe).await {
                            client.state.push_log(format!("Move failed: {err}"));
                        }
                        was_moving = true;
                    } else if was_moving {
                        if let Err(err) = client.stop().await {
                            client.state.push_log(format!("Stop failed: {err}"));
                        }
                        was_moving = false;
                    }
                }
            }
            maybe_key = key_rx.recv() => {
                if let Some(Event::Key(key)) = maybe_key {
                    if chat_mode {
                        match key.kind {
                            KeyEventKind::Press => match key.code {
                                KeyCode::Esc => {
                                    chat_mode = false;
                                    chat_buffer.clear();
                                }
                                KeyCode::Enter => {
                                    if !chat_buffer.trim().is_empty() {
                                        let channel = if chat_whisper {
                                            ChatChannel::WhisperStone
                                        } else {
                                            ChatChannel::Nearby
                                        };
                                        if let Err(err) =
                                            client.say(channel, chat_buffer.trim()).await
                                        {
                                            client.state.push_log(format!("Chat failed: {err}"));
                                        }
                                    }
                                    chat_mode = false;
                                    chat_buffer.clear();
                                }
                                KeyCode::Backspace => {
                                    chat_buffer.pop();
                                }
                                KeyCode::Char(c) => {
                                    if chat_buffer.len() < 200 {
                                        chat_buffer.push(c);
                                    }
                                }
                                _ => {}
                            },
                            _ => {}
                        }
                        continue;
                    }

                    if client.state.show_craft_menu {
                        if key.kind == KeyEventKind::Press {
                            match key.code {
                                KeyCode::Esc | KeyCode::Char('c') => {
                                    client.close_craft_menu();
                                }
                                KeyCode::Up | KeyCode::Char('k') => {
                                    client.craft_menu_move(-1);
                                }
                                KeyCode::Down | KeyCode::Char('j') => {
                                    client.craft_menu_move(1);
                                }
                                KeyCode::Enter => {
                                    if let Err(err) = client.craft_menu_selection().await {
                                        client.state.push_log(format!("Craft failed: {err}"));
                                    }
                                }
                                _ => {}
                            }
                        }
                        continue;
                    }

                    match movement_state.apply_key(key) {
                        InputAction::Quit => {
                            if session_started.elapsed() < Duration::from_millis(400) {
                                warn!("ignoring early quit key (terminal focus noise)");
                                continue;
                            }
                            user_quit = true;
                            break;
                        }
                        InputAction::Harvest => {
                            if let Err(err) = client.harvest_nearest().await {
                                client.state.push_log(format!("Harvest failed: {err}"));
                            }
                        }
                        InputAction::Craft => {
                            client.open_craft_menu();
                        }
                        InputAction::Interact => {
                            if let Err(err) = client.interact_nearest().await {
                                client.state.push_log(format!("Interact failed: {err}"));
                            }
                        }
                        InputAction::TestDamage => {
                            if let Err(err) = client.test_damage(25.0).await {
                                client.state.push_log(format!("Damage failed: {err}"));
                            }
                        }
                        InputAction::ToggleStats => {
                            client.toggle_stats();
                        }
                        InputAction::StartChat { whisper } => {
                            if whisper && !client.state.inventory.contains_key("whisper_stone") {
                                client.state.push_log("Need whisper_stone (harvest whisper shrine)");
                            } else {
                                chat_mode = true;
                                chat_whisper = whisper;
                                chat_buffer.clear();
                            }
                        }
                        InputAction::SubmitChat | InputAction::CancelChat => {}
                        InputAction::None => {}
                    }
                }
            }
        }
    }

    if user_quit {
        info!("quit");
    }
    client.disconnect();
    let _ = ratatui.show_cursor();

    Ok(())
}