diff options
| author | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
| commit | a726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch) | |
| tree | 4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /crates/tui/src | |
| parent | 8c95e90fa54db565919ec323818f77e7256812c8 (diff) | |
| download | typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip | |
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
still filling, or opens one. Players never see a room code; joining by
code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
empty lobby. They only ever join quick-match rooms, never a room opened
by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
two bots are never near each other's pace, and they stall to correct
mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
which is what reset clients mid-countdown.
Typing languages
- 16 languages for the generated-word modes, each with its own
high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
difficulty tier instead of falling back to English words.
Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
accumulated byte-length deltas while total_correct_characters compared a
char index against that byte count. Equal on ASCII, so it went unnoticed;
a correctly typed Spanish passage scored 6%. The old byte slicing would
also have panicked if an index landed inside a multi-byte character.
Rewritten char-based, with regression tests.
Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
by necessity, since the typing input is a single-line field.
Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
column is a whole number of characters wide so wrapping cannot leave a
permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
carried over from the typing screen, and a graph with a separate error
axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'crates/tui/src')
| -rw-r--r-- | crates/tui/src/config_file.rs | 51 | ||||
| -rw-r--r-- | crates/tui/src/main.rs | 222 | ||||
| -rw-r--r-- | crates/tui/src/multiplayer_net.rs | 183 | ||||
| -rw-r--r-- | crates/tui/src/net.rs | 193 |
4 files changed, 649 insertions, 0 deletions
diff --git a/crates/tui/src/config_file.rs b/crates/tui/src/config_file.rs new file mode 100644 index 0000000..cc9f05f --- /dev/null +++ b/crates/tui/src/config_file.rs @@ -0,0 +1,51 @@ +// Local config file for the TUI's account state - a saved personal-access +// token, not a browser cookie, since a terminal app has no cookie jar. +// Lives at the OS config dir so it survives between runs the same way any +// other CLI tool's saved credentials would. +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthConfig { + pub token: Option<String>, + pub username: Option<String>, +} + +fn config_path() -> Option<PathBuf> { + let mut dir = dirs::config_dir()?; + dir.push("typerpunk"); + Some(dir.join("cli_auth.json")) +} + +pub fn load() -> AuthConfig { + let Some(path) = config_path() else { return AuthConfig::default() }; + let Ok(raw) = std::fs::read_to_string(&path) else { return AuthConfig::default() }; + serde_json::from_str(&raw).unwrap_or_default() +} + +pub fn save(config: &AuthConfig) { + let Some(path) = config_path() else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(config) { + let _ = std::fs::write(&path, json); + } +} + +pub fn server_url() -> String { + std::env::var("TYPERPUNK_SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) +} + +/// Same host as server_url(), just ws:// (or wss:// if the HTTP base was +/// already secured) instead of http(s):// - for the multiplayer WebSocket. +pub fn server_ws_url() -> String { + let http = server_url(); + if let Some(rest) = http.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = http.strip_prefix("http://") { + format!("ws://{rest}") + } else { + format!("ws://{http}") + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..f5c4a68 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,222 @@ +mod config_file; +mod multiplayer_net; +mod net; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use multiplayer_net::{LocalUpdate, MultiplayerConnection, NetEvent as MpNetEvent}; +use net::{NetClient, NetResponse}; +use ratatui::{ + backend::CrosstermBackend, + Terminal, +}; +use std::{io, error::Error as StdError}; +use typerpunk_core::{ + app::{App, MpPlayer, NetworkAction, State}, + input::InputHandler, + ui::draw, +}; + +fn main() -> Result<(), Box<dyn StdError>> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app and run it + let mut app = match App::new() { + Ok(app) => app, + Err(e) => { + cleanup_terminal(&mut terminal)?; + return Err(e); + } + }; + + // A saved token means a returning user doesn't have to log in again -- + // restored eagerly so the main menu's account line reflects it from the + // very first frame. + let saved_auth = config_file::load(); + if let (Some(token), Some(username)) = (saved_auth.token, saved_auth.username) { + app.restore_session(token, username); + } + + let net_client = NetClient::spawn(); + + let mut input_handler = InputHandler::new(app); + let res = run_app(&mut terminal, &mut input_handler, &net_client); + + // Restore terminal + cleanup_terminal(&mut terminal)?; + + if let Err(err) = res { + println!("Error: {:?}", err); + } + + Ok(()) +} + +fn cleanup_terminal<B: ratatui::backend::Backend + std::io::Write>(terminal: &mut Terminal<B>) -> io::Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn apply_net_response(app: &mut App, response: NetResponse) { + match response { + NetResponse::LoginOk { username, token } => { + app.set_login_success(username.clone(), token.clone()); + // Only a real login/register issues a fresh token - persist it + // so the next launch starts already signed in. + if let Some(token) = token { + config_file::save(&config_file::AuthConfig { token: Some(token), username: Some(username) }); + } + } + NetResponse::Error(message) => app.set_net_error(message), + NetResponse::Leaderboard(entries) => app.set_leaderboard(entries), + NetResponse::Friends { list, incoming, outgoing } => app.set_friends(list, incoming, outgoing), + } +} + +// PlayerList arrives every time someone joins/readies/leaves, not just +// once - resetting progress/wpm to zero on every refresh would erase a +// mid-race player's visible position each time the roster is re-sent, so +// existing entries carry theirs forward instead of being rebuilt from +// scratch. +fn merge_player_list(existing: &[MpPlayer], incoming: Vec<typerpunk_core::multiplayer::PlayerInfo>) -> Vec<MpPlayer> { + incoming + .into_iter() + .map(|p| { + let (progress, wpm) = existing.iter().find(|e| e.id == p.id).map(|e| (e.progress, e.wpm)).unwrap_or((0.0, 0.0)); + MpPlayer { id: p.id, name: p.name, ready: p.ready, progress, wpm } + }) + .collect() +} + +fn apply_mp_event(app: &mut App, event: MpNetEvent) { + match event { + MpNetEvent::RoomCreated { code } => app.set_mp_room_created(code), + MpNetEvent::Joined { player_id } => app.set_mp_joined(player_id), + MpNetEvent::PlayerList(players) => { + let merged = merge_player_list(&app.mp_players, players); + app.set_mp_player_list(merged); + } + MpNetEvent::Countdown(seconds) => app.set_mp_countdown(seconds), + MpNetEvent::Start(text) => app.set_mp_start(text), + MpNetEvent::PlayerProgress { player_id, percent, wpm } => app.set_mp_player_progress(&player_id, percent, wpm), + MpNetEvent::PlayerFinished { player_id, wpm, place } => app.set_mp_player_finished(&player_id, "opponent", wpm, place), + MpNetEvent::RoomClosed(reason) => app.set_mp_room_closed(reason), + MpNetEvent::Error(message) => app.set_mp_error(message), + } +} + +fn run_app<B: ratatui::backend::Backend>( + terminal: &mut Terminal<B>, + input_handler: &mut InputHandler, + net_client: &NetClient, +) -> io::Result<()> { + let mut last_render = std::time::Instant::now(); + let render_interval = std::time::Duration::from_millis(16); // ~60 FPS + let mut mp_connection: Option<MultiplayerConnection> = None; + + loop { + // Update app state to refresh timers and stats + input_handler.app.update(); + + // Drain whatever the network workers have finished since last frame + // - never blocks, since try_recv() only returns what's already + // there. + while let Some(response) = net_client.try_recv() { + apply_net_response(&mut input_handler.app, response); + } + if let Some(conn) = &mp_connection { + while let Some(event) = conn.try_recv() { + apply_mp_event(&mut input_handler.app, event); + } + } + + // Leaving multiplayer entirely (Esc from lobby/race/results resets + // this on the app side) drops the connection here too, so the + // background thread notices its channels are gone and closes the + // socket instead of lingering for the rest of the process. + let in_multiplayer = matches!( + input_handler.app.state, + State::MultiplayerLobby | State::MultiplayerRace | State::MultiplayerResults + ); + if !in_multiplayer && mp_connection.is_some() { + mp_connection = None; + } + + // A race that's finished typing (is_finished(), handled inside + // app.rs's own tick) but hasn't yet heard the server confirm it via + // PlayerFinished still needs its Finish message actually sent -- + // driven here every frame rather than per-keypress, since finishing + // is a state the typist arrives at continuously, not a discrete key. + if input_handler.app.state == State::MultiplayerRace && !input_handler.app.mp_finish_sent { + if input_handler.app.is_finished() { + if let Some(conn) = &mp_connection { + conn.send(LocalUpdate::Finish { + wpm: input_handler.app.stats.wpm() as f32, + accuracy: input_handler.app.stats.accuracy() as f32, + time: input_handler.app.stats.elapsed_time().as_secs_f32(), + }); + } + input_handler.app.mp_finish_sent = true; + } else if let Some(conn) = &mp_connection { + let percent = input_handler.app.get_progress() as f32; + let wpm = input_handler.app.stats.wpm() as f32; + conn.send(LocalUpdate::Progress { percent, wpm }); + } + } + + terminal.draw(|f| draw(f, &input_handler.app))?; + + if event::poll(std::time::Duration::from_millis(0))? { + if let Event::Key(key) = event::read()? { + input_handler.app.handle_input(key); + if let Some(action) = input_handler.app.pending_network_action.take() { + match action { + NetworkAction::CreateMultiplayerRoom => { + let name = input_handler.app.logged_in_username.clone().unwrap_or_else(|| "Guest".to_string()); + mp_connection = Some(MultiplayerConnection::spawn(config_file::server_url(), config_file::server_ws_url(), None, name)); + } + NetworkAction::JoinMultiplayerRoom { code } => { + let name = input_handler.app.logged_in_username.clone().unwrap_or_else(|| "Guest".to_string()); + mp_connection = Some(MultiplayerConnection::spawn(config_file::server_url(), config_file::server_ws_url(), Some(code), name)); + } + NetworkAction::MultiplayerReady => { + if let Some(conn) = &mp_connection { + conn.send(LocalUpdate::Ready); + } + input_handler.app.net_busy = false; + } + other => net_client.send(other, input_handler.app.auth_token.clone()), + } + } + if input_handler.app.should_exit { + return Ok(()); + } + } + } + + // Limit render rate + let now = std::time::Instant::now(); + if now.duration_since(last_render) < render_interval { + std::thread::sleep(render_interval - now.duration_since(last_render)); + } + last_render = now; + + if input_handler.app.should_exit { + return Ok(()); + } + } +}
\ No newline at end of file diff --git a/crates/tui/src/multiplayer_net.rs b/crates/tui/src/multiplayer_net.rs new file mode 100644 index 0000000..8c6c6b0 --- /dev/null +++ b/crates/tui/src/multiplayer_net.rs @@ -0,0 +1,183 @@ +// Real-time WebSocket networking for TUI Multiplayer, alongside net.rs's +// one-shot REST calls (login/leaderboard/friends). Same background-thread +// pattern as crates/steam/src/net.rs (which speaks the identical wire +// protocol against the same server) - a dedicated OS thread owns a tokio +// runtime and the live connection, bridging to the sync render loop over +// plain std::sync::mpsc channels polled once per frame. +use futures_util::{SinkExt, StreamExt}; +use std::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +#[derive(Debug, Clone)] +pub enum NetEvent { + RoomCreated { code: String }, + Joined { player_id: String }, + PlayerList(Vec<PlayerInfo>), + Countdown(u32), + Start(String), + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, place: u32 }, + RoomClosed(String), + Error(String), +} + +#[derive(Debug, Clone)] +pub enum LocalUpdate { + Ready, + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +pub struct MultiplayerConnection { + event_rx: std::sync::Mutex<mpsc::Receiver<NetEvent>>, + update_tx: mpsc::Sender<LocalUpdate>, +} + +impl MultiplayerConnection { + /// `room_code: None` creates a fresh room (POSTs to the REST endpoint + /// first, then connects); `Some(code)` connects straight to that + /// existing room's WebSocket. + pub fn spawn(http_base: String, ws_base: String, room_code: Option<String>, name: String) -> Self { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::<LocalUpdate>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to start multiplayer network runtime"); + rt.block_on(run(http_base, ws_base, room_code, name, event_tx, update_rx)); + }); + + Self { event_rx: std::sync::Mutex::new(event_rx), update_tx } + } + + pub fn try_recv(&self) -> Option<NetEvent> { + self.event_rx.lock().ok()?.try_recv().ok() + } + + pub fn send(&self, update: LocalUpdate) { + let _ = self.update_tx.send(update); + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result<String, String> { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{http_base}/api/multiplayer/rooms")) + .send() + .await + .map_err(|e| format!("could not reach server: {e}"))?; + if !resp.status().is_success() { + return Err(format!("room creation failed ({})", resp.status())); + } + resp.json::<CreateRoomResponse>().await.map(|b| b.room_code).map_err(|e| format!("bad response: {e}")) +} + +fn to_event(msg: ServerMessage) -> NetEvent { + match msg { + ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, + ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), + ServerMessage::Start { text } => NetEvent::Start(text), + ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, + ServerMessage::PlayerFinished { player_id, wpm, place, .. } => NetEvent::PlayerFinished { player_id, wpm, place }, + ServerMessage::RoomClosed { reason } => NetEvent::RoomClosed(reason), + ServerMessage::Error { message } => NetEvent::Error(message), + } +} + +async fn send_msg<S>(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink<WsMessage> + Unpin, +{ + let text = serde_json::to_string(msg).map_err(|_| ())?; + write.send(WsMessage::Text(text)).await.map_err(|_| ()) +} + +async fn run( + http_base: String, + ws_base: String, + room_code: Option<String>, + name: String, + event_tx: mpsc::Sender<NetEvent>, + update_rx: mpsc::Receiver<LocalUpdate>, +) { + let code = match room_code { + Some(code) => code, + None => match create_room(&http_base).await { + Ok(code) => { + let _ = event_tx.send(NetEvent::RoomCreated { code: code.clone() }); + code + } + Err(err) => { + let _ = event_tx.send(NetEvent::Error(err)); + return; + } + }, + }; + + let url = format!("{ws_base}/ws/multiplayer/{code}"); + let (ws_stream, _) = match tokio_tungstenite::connect_async(&url).await { + Ok(pair) => pair, + Err(err) => { + let _ = event_tx.send(NetEvent::Error(format!("connect failed: {err}"))); + return; + } + }; + let (mut write, mut read) = ws_stream.split(); + + let join = ClientMessage::Join { name, device_type: "desktop".to_string() }; + if send_msg(&mut write, &join).await.is_err() { + let _ = event_tx.send(NetEvent::Error("failed to send join".to_string())); + return; + } + + loop { + tokio::select! { + incoming = read.next() => { + match incoming { + Some(Ok(WsMessage::Text(text))) => { + if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) { + if event_tx.send(to_event(msg)).is_err() { return; } + } + } + Some(Ok(WsMessage::Close(_))) | None => { + let _ = event_tx.send(NetEvent::RoomClosed("connection closed".to_string())); + return; + } + Some(Err(err)) => { + let _ = event_tx.send(NetEvent::Error(format!("connection error: {err}"))); + return; + } + _ => {} + } + } + // std::sync::mpsc has no async recv - polled on a short + // interval, same pattern as crates/steam/src/net.rs. Short + // enough that a manual "r: ready" keypress feels responsive. + _ = tokio::time::sleep(std::time::Duration::from_millis(80)) => { + loop { + match update_rx.try_recv() { + Ok(update) => { + let client_msg = match update { + LocalUpdate::Ready => ClientMessage::Ready, + LocalUpdate::Progress { percent, wpm } => ClientMessage::Progress { percent, wpm }, + LocalUpdate::Finish { wpm, accuracy, time } => ClientMessage::Finish { wpm, accuracy, time }, + }; + if send_msg(&mut write, &client_msg).await.is_err() { return; } + } + Err(mpsc::TryRecvError::Empty) => break, + // main.rs dropped its MultiplayerConnection (left the + // room/app) - nothing left to send to, close out + // instead of holding the socket open indefinitely. + Err(mpsc::TryRecvError::Disconnected) => return, + } + } + } + } + } +} diff --git a/crates/tui/src/net.rs b/crates/tui/src/net.rs new file mode 100644 index 0000000..a0e55da --- /dev/null +++ b/crates/tui/src/net.rs @@ -0,0 +1,193 @@ +// Background network worker for the TUI. The render loop in main.rs is a +// plain synchronous 60fps loop (see run_app) - rather than restructure that +// into async, a dedicated OS thread owns its own tokio runtime and a small +// reqwest client, communicating over plain std::sync::mpsc channels. The +// main loop sends a Job after each keypress that queues one, and polls +// try_recv() once per frame to apply whatever came back. +use crate::config_file::server_url; +use serde::Deserialize; +use std::sync::mpsc; +use typerpunk_core::app::{FriendRow, LeaderboardRow, NetworkAction}; + +pub enum NetResponse { + LoginOk { username: String, token: Option<String> }, + Error(String), + Leaderboard(Vec<LeaderboardRow>), + Friends { list: Vec<FriendRow>, incoming: Vec<FriendRow>, outgoing: Vec<FriendRow> }, +} + +struct Job { + action: NetworkAction, + token: Option<String>, +} + +pub struct NetClient { + tx: mpsc::Sender<Job>, + rx: mpsc::Receiver<NetResponse>, +} + +impl NetClient { + pub fn spawn() -> Self { + let (job_tx, job_rx) = mpsc::channel::<Job>(); + let (resp_tx, resp_rx) = mpsc::channel::<NetResponse>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to start TUI network runtime"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("failed to build http client"); + let base = server_url(); + + while let Ok(job) = job_rx.recv() { + let response = rt.block_on(run_job(&client, &base, job)); + if resp_tx.send(response).is_err() { + break; // main loop is gone + } + } + }); + + Self { tx: job_tx, rx: resp_rx } + } + + pub fn send(&self, action: NetworkAction, token: Option<String>) { + let _ = self.tx.send(Job { action, token }); + } + + pub fn try_recv(&self) -> Option<NetResponse> { + self.rx.try_recv().ok() + } +} + +#[derive(Deserialize)] +struct ErrorBody { + error: String, +} + +async fn error_message(resp: reqwest::Response) -> String { + let status = resp.status(); + match resp.json::<ErrorBody>().await { + Ok(body) => body.error, + Err(_) => format!("request failed ({status})"), + } +} + +#[derive(Deserialize)] +struct UserView { + username: String, +} + +#[derive(Deserialize)] +struct TokenResponse { + token: String, + user: UserView, +} + +async fn run_job(client: &reqwest::Client, base: &str, job: Job) -> NetResponse { + match job.action { + NetworkAction::Login { username, password } => login(client, base, &username, &password).await, + NetworkAction::Register { username, password } => { + let register_url = format!("{base}/api/auth/register"); + match client.post(®ister_url).json(&serde_json::json!({ "username": username, "password": password })).send().await { + Ok(resp) if resp.status().is_success() => login(client, base, &username, &password).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::FetchLeaderboard { mode } => { + let url = format!("{base}/api/leaderboard?mode={mode}"); + match client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<Vec<LeaderboardRow>>().await { + Ok(rows) => NetResponse::Leaderboard(rows), + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::FetchFriends => fetch_friends(client, base, job.token.as_deref()).await, + NetworkAction::SendFriendRequest { username } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/request"); + match client.post(&url).bearer_auth(token).json(&serde_json::json!({ "username": username })).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::AcceptFriendRequest { id } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/{id}/accept"); + match client.post(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + NetworkAction::RemoveFriendship { id } => { + let Some(token) = job.token.as_deref() else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends/{id}"); + match client.delete(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => fetch_friends(client, base, Some(token)).await, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } + } + // Handled entirely by main.rs's own MultiplayerConnection instead + // (a persistent WebSocket, not a one-shot REST job) - these never + // actually reach this worker; this arm only exists so the match + // stays exhaustive over the shared NetworkAction enum. + NetworkAction::CreateMultiplayerRoom | NetworkAction::JoinMultiplayerRoom { .. } | NetworkAction::MultiplayerReady => { + NetResponse::Error("multiplayer action sent to the wrong network worker".to_string()) + } + } +} + +async fn login(client: &reqwest::Client, base: &str, username: &str, password: &str) -> NetResponse { + let url = format!("{base}/api/auth/token"); + match client.post(&url).json(&serde_json::json!({ "username": username, "password": password })).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<TokenResponse>().await { + Ok(body) => NetResponse::LoginOk { username: body.user.username, token: Some(body.token) }, + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } +} + +#[derive(Deserialize)] +struct FriendEntry { + friendship_id: String, + username: String, +} + +#[derive(Deserialize)] +struct FriendsListBody { + friends: Vec<FriendEntry>, + incoming_requests: Vec<FriendEntry>, + outgoing_requests: Vec<FriendEntry>, +} + +fn to_rows(entries: Vec<FriendEntry>) -> Vec<FriendRow> { + entries.into_iter().map(|e| FriendRow { friendship_id: e.friendship_id, username: e.username }).collect() +} + +async fn fetch_friends(client: &reqwest::Client, base: &str, token: Option<&str>) -> NetResponse { + let Some(token) = token else { return NetResponse::Error("not signed in".to_string()) }; + let url = format!("{base}/api/friends"); + match client.get(&url).bearer_auth(token).send().await { + Ok(resp) if resp.status().is_success() => match resp.json::<FriendsListBody>().await { + Ok(body) => NetResponse::Friends { + list: to_rows(body.friends), + incoming: to_rows(body.incoming_requests), + outgoing: to_rows(body.outgoing_requests), + }, + Err(err) => NetResponse::Error(format!("bad response: {err}")), + }, + Ok(resp) => NetResponse::Error(error_message(resp).await), + Err(err) => NetResponse::Error(format!("network error: {err}")), + } +} |