srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/tui/src
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-11 21:53:00 +0200
committersrdusr <[email protected]>2025-09-11 21:53:00 +0200
commita726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch)
tree4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /crates/tui/src
parent8c95e90fa54db565919ec323818f77e7256812c8 (diff)
downloadtyperpunk-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.rs51
-rw-r--r--crates/tui/src/main.rs222
-rw-r--r--crates/tui/src/multiplayer_net.rs183
-rw-r--r--crates/tui/src/net.rs193
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(&register_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}")),
+ }
+}