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 {
#[arg(short, long, default_value = "traveler")]
pub name: String,
#[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;
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(())
}