use futures::channel::mpsc; use futures::{FutureExt, StreamExt}; use gloo_storage::{LocalStorage, Storage}; use leptos::prelude::*; use leptos::task::spawn_local; use leptos_router::components::{Route, Router, Routes, A}; use leptos_router::hooks::use_location; use leptos_router::path; use serde::{Deserialize, Serialize}; use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; use backbone_lib::traits::ViewStateUpdate; use crate::api; use crate::game::components::{ConnectingScreen, GameScreen}; use crate::game::session::{ compute_last_moves, patch_player_name, push_or_show, run_local_bot_game, run_local_bot_game_with_backend, }; use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; use crate::i18n::*; use crate::portal::{ account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage, verify_email::VerifyEmailPage, }; use trictrac_store::CheckerMove; use std::collections::VecDeque; const RELAY_URL: &str = "ws://localhost:8080/ws"; const GAME_ID: &str = "trictrac"; const STORAGE_KEY: &str = "trictrac_session"; /// The state the UI needs to render the game screen. #[derive(Clone, PartialEq)] pub struct GameUiState { pub view_state: ViewState, /// 0 = host, 1 = guest pub player_id: u16, pub room_id: String, pub is_bot_game: bool, pub waiting_for_confirm: bool, pub pause_reason: Option, pub my_scored_event: Option, pub opp_scored_event: Option, pub last_moves: Option<(CheckerMove, CheckerMove)>, /// True on the echo screen state set alongside a pending item — suppresses dice /// roll animation and sound since they already played on the pending screen. pub suppress_dice_anim: bool, } /// Reason the UI is paused waiting for the player to click Continue. #[derive(Clone, Debug, PartialEq)] pub enum PauseReason { AfterOpponentRoll, AfterOpponentGo, AfterOpponentMove, AfterOpponentPreGameRoll, } /// Which screen is currently shown (used to toggle game overlay). #[derive(Clone, PartialEq)] pub enum Screen { Login { error: Option }, Connecting, Playing(GameUiState), } /// Commands sent from UI event handlers into the network task. pub enum NetCommand { CreateRoom { room: String, }, JoinRoom { room: String, }, Reconnect { relay_url: String, game_id: String, room_id: String, token: u64, host_state: Option>, }, PlayVsBot, /// Start a bot game with the board/score position from a previously taken snapshot. ReplaySnapshot(ViewState), Action(PlayerAction), Disconnect, } #[derive(Serialize, Deserialize)] struct StoredSession { relay_url: String, game_id: String, room_id: String, token: u64, #[serde(default)] is_host: bool, #[serde(default)] view_state: Option, } fn save_session(session: &StoredSession) { LocalStorage::set(STORAGE_KEY, session).ok(); } fn load_session() -> Option { LocalStorage::get::(STORAGE_KEY).ok() } fn clear_session() { LocalStorage::delete(STORAGE_KEY); } async fn submit_game_result(room_code: String, game_state: ViewState) { let [score_pl1, score_pl2] = game_state.scores; let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes); let outcomes = if score_pl1.holes < score_pl2.holes { [("0", "loss"), ("1", "win")] } else if score_pl2.holes < score_pl1.holes { [("0", "win"), ("1", "loss")] } else { [("0", "draw"), ("1", "draw")] }; let body = serde_json::json!({ "room_code": room_code, "game_id": GAME_ID, "result": result_str, "outcomes": std::collections::HashMap::from(outcomes), }); let _ = gloo_net::http::Request::post(&format!("{}/games/result", api::HTTP_BASE)) .credentials(web_sys::RequestCredentials::Include) .json(&body) .unwrap() .send() .await; } #[component] pub fn App() -> impl IntoView { let i18n = use_i18n(); let stored = load_session(); let initial_screen = if stored.is_some() { Screen::Connecting } else { Screen::Login { error: None } }; let screen: RwSignal = RwSignal::new(initial_screen); provide_context(screen); // Auth: fetch once on load; shared by nav + game + portal components. let auth_username: RwSignal> = RwSignal::new(None); let auth_email_verified: RwSignal = RwSignal::new(false); provide_context(auth_username); provide_context(auth_email_verified); // Set to true once get_me resolves (success or failure) so lobby can // decide immediately whether to show the nickname modal. let auth_loaded: RwSignal = RwSignal::new(false); provide_context(auth_loaded); // Nickname chosen by an anonymous player; used instead of "Anonymous". let anon_nickname: RwSignal> = RwSignal::new(None); provide_context(anon_nickname); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); auth_email_verified.set(me.email_verified); } auth_loaded.set(true); }); let (cmd_tx, mut cmd_rx) = mpsc::unbounded::(); let pending: RwSignal> = RwSignal::new(VecDeque::new()); provide_context(pending); provide_context(cmd_tx.clone()); if let Some(s) = stored { let host_state = s .view_state .as_ref() .and_then(|vs| serde_json::to_vec(vs).ok()); cmd_tx .unbounded_send(NetCommand::Reconnect { relay_url: s.relay_url, game_id: s.game_id, room_id: s.room_id, token: s.token, host_state, }) .ok(); } spawn_local(async move { loop { let mut snapshot_init: Option = None; let remote_config: Option<(RoomConfig, bool)> = loop { match cmd_rx.next().await { Some(NetCommand::PlayVsBot) => break None, Some(NetCommand::ReplaySnapshot(vs)) => { snapshot_init = Some(vs); break None; } Some(NetCommand::CreateRoom { room }) => { break Some(( RoomConfig { relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, role: RoomRole::Create, reconnect_token: None, host_state: None, }, false, )); } Some(NetCommand::JoinRoom { room }) => { break Some(( RoomConfig { relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, role: RoomRole::Join, reconnect_token: None, host_state: None, }, false, )); } Some(NetCommand::Reconnect { relay_url, game_id, room_id, token, host_state, }) => { break Some(( RoomConfig { relay_url, game_id, room_id, rule_variation: 0, role: RoomRole::Join, reconnect_token: Some(token), host_state, }, true, )); } _ => {} } }; if remote_config.is_none() { let player_name = auth_username .get_untracked() .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string())); loop { let restart = match snapshot_init.take() { Some(vs) => { let backend = TrictracBackend::from_view_state(vs, &player_name); run_local_bot_game_with_backend( screen, &mut cmd_rx, pending, player_name.clone(), backend, ) .await } None => { run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()) .await } }; if !restart { break; } } pending.update(|q| q.clear()); screen.set(Screen::Login { error: None }); continue; } let (config, is_reconnect) = remote_config.unwrap(); screen.set(Screen::Connecting); let room_id_for_storage = config.room_id.clone(); let mut session: GameSession = match GameSession::connect::(config).await { Ok(s) => s, Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => { if is_reconnect { clear_session(); } screen.set(Screen::Login { error: Some(e) }); continue; } }; if !session.is_host { save_session(&StoredSession { relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: session.reconnect_token, is_host: false, view_state: None, }); } let is_host = session.is_host; let player_id = session.player_id; let reconnect_token = session.reconnect_token; let my_name = auth_username .get_untracked() .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string()); // Announce our name to the host backend so it can broadcast it to // the opponent. Done once immediately after connecting. session.send_action(PlayerAction::SetName(my_name.clone())); let mut vs = ViewState::default_with_names("", ""); let mut result_submitted = false; loop { futures::select! { cmd = cmd_rx.next().fuse() => match cmd { Some(NetCommand::Action(action)) => { session.send_action(action); } _ => { clear_session(); session.disconnect(); pending.update(|q| q.clear()); screen.set(Screen::Login { error: None }); break; } }, event = session.next_event().fuse() => match event { Some(SessionEvent::Update(u)) => { let prev_vs = vs.clone(); match u { ViewStateUpdate::Full(state) => vs = state, ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta), } patch_player_name(&mut vs, player_id, &my_name); if is_host && !result_submitted && vs.stage == SerStage::Ended { result_submitted = true; let room = room_id_for_storage.clone(); let gs = vs.clone(); spawn_local(submit_game_result(room, gs)); } if is_host { save_session(&StoredSession { relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: reconnect_token, is_host: true, view_state: Some(vs.clone()), }); } let is_own_move = prev_vs.active_mp_player == Some(player_id); push_or_show( &prev_vs, GameUiState { view_state: vs.clone(), player_id, room_id: room_id_for_storage.clone(), is_bot_game: false, waiting_for_confirm: false, pause_reason: None, my_scored_event: None, opp_scored_event: None, last_moves: compute_last_moves(&prev_vs, &vs, is_own_move), suppress_dice_anim: false, }, pending, screen, ); } Some(SessionEvent::Disconnected(reason)) => { pending.update(|q| q.clear()); screen.set(Screen::Login { error: reason }); break; } None => { pending.update(|q| q.clear()); screen.set(Screen::Login { error: None }); break; } } } } } }); view! {
"Page not found."

}>
} } /// Renders the full-screen game overlay, but only when the current route is "/". /// This lets the user navigate to profile/account pages while a game is running. #[component] fn GameOverlay( pending: RwSignal>, screen: RwSignal, ) -> impl IntoView { let location = use_location(); // Memoize the front of the pending queue so that pushing a new item to the back // does not re-mount GameScreen (and replay dice animation/sound) when the displayed // state (the front) hasn't changed. let pending_front = Memo::new(move |_| pending.with(|q| q.front().cloned())); move || { if location.pathname.get() != "/" { return view! {}.into_any(); } if let Some(state) = pending_front.get() { return view! {
} .into_any(); } match screen.get() { Screen::Playing(state) => view! {
} .into_any(), Screen::Connecting => view! {
} .into_any(), _ => view! {}.into_any(), } } } /// Persistent hamburger button + left sidebar — visible on every page. #[component] fn SiteHamburger() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().unwrap_or_else(|| RwSignal::new(None)); let screen = use_context::>().expect("Screen context not found"); let cmd_tx = use_context::>() .expect("cmd_tx not found in context"); let sidebar_open = RwSignal::new(false); let snapshot_copied = RwSignal::new(false); let replay_open = RwSignal::new(false); let replay_text = RwSignal::new(String::new()); let replay_error = RwSignal::new(false); let cmd_tx_newgame = cmd_tx.clone(); let cmd_tx_snapshot = cmd_tx.clone(); let cmd_tx_replay = cmd_tx.clone(); view! { // ── Hamburger button (☰ → ✕ animation) ─────────────────────────────── // ── Left sidebar ──────────────────────────────────────────────────────
"Trictrac"
// Language switcher //
// // // // {t!(i18n, language)} //
// // //
//
{move || { let tx = cmd_tx_newgame.clone(); Some(view! { {t!(i18n, new_game)} }) }}
// Auth
{move || match auth_username.get() { Some(u) => { let href = format!("/profile/{u}"); view! { {u} }.into_any() }, None => view! { {t!(i18n, sign_in)} }.into_any(), }}
// ── Debug section ─────────────────────────────────────────────────
{t!(i18n, debug_section)} // "Take snapshot" — only visible while a game is in progress {move || { let Screen::Playing(ref state) = screen.get() else { return None; }; let vs = state.view_state.clone(); let tx = cmd_tx_snapshot.clone(); Some(view! { }) }} // "Replay snapshot" — always visible
// ── Replay snapshot modal ─────────────────────────────────────────────

{t!(i18n, replay_snapshot)}

{t!(i18n, replay_paste_hint)}