diff --git a/phira-mp-client/src/lib.rs b/phira-mp-client/src/lib.rs index c80ba99..3000f5a 100644 --- a/phira-mp-client/src/lib.rs +++ b/phira-mp-client/src/lib.rs @@ -7,6 +7,7 @@ use phira_mp_common::{ JudgeEvent, Message, RoomId, RoomState, ServerCommand, Stream, TouchFrame, UserInfo, }; use std::{ + collections::VecDeque, sync::{ Arc, atomic::{AtomicU8, Ordering}, @@ -25,6 +26,31 @@ type Callback = Mutex>>; type RCallback = Mutex>>>; pub const TIMEOUT: Duration = Duration::from_secs(7); +/// 本地谱面上传/下载等大数据传输的超时(经 game 连接传整个 zip,需更长)。 +pub const TRANSFER_TIMEOUT: Duration = Duration::from_secs(120); + +/// 本地谱面同步(LocalChart)触发的事件,由服务端主动推送,上层通过 +/// [`Client::blocking_take_local_chart_events`] 轮询消费。 +#[derive(Clone, Debug)] +pub enum LocalChartEvent { + /// 房间进入 / 退出本地谱面分享状态 + ChangeLocalChart { local: bool, chart_id: String }, + /// 房主:服务端要求启动本地下载服务器 + StartServing { chart_id: String, chart_name: String }, + /// 玩家:服务端指示从房主下载谱面 + StartDownload { + host_id: i32, + host_name: String, + addr: String, + port: u16, + chart_id: String, + chart_name: String, + }, + /// 房主:所有玩家都已完成下载 + HostReady, + /// 房主取消了本地谱面分享:所有客户端应重置就绪/开始按钮状态 + Canceled, +} pub struct LivePlayer { pub touch_frames: Mutex>, @@ -67,6 +93,18 @@ struct State { cb_played: RCallback<()>, cb_abort: RCallback<()>, + cb_select_local_chart: RCallback<()>, + cb_select_online_chart: RCallback<()>, + cb_send_chart: RCallback<()>, + cb_download_ready: RCallback<()>, + cb_cancel_local_chart: RCallback<()>, + cb_cancel_download_ready: RCallback<()>, + + cb_upload_chart: RCallback<()>, + cb_download_chart: RCallback>, + + local_chart_events: Mutex>, + live_players: DashMap>, messages: Mutex>, } @@ -116,6 +154,18 @@ impl Client { cb_played: Callback::default(), cb_abort: Callback::default(), + cb_select_local_chart: Callback::default(), + cb_select_online_chart: Callback::default(), + cb_send_chart: Callback::default(), + cb_download_ready: Callback::default(), + cb_cancel_local_chart: Callback::default(), + cb_cancel_download_ready: Callback::default(), + + cb_upload_chart: Callback::default(), + cb_download_chart: Callback::default(), + + local_chart_events: Mutex::default(), + live_players: DashMap::new(), messages: Mutex::default(), }); @@ -123,6 +173,7 @@ impl Client { Stream::new( Some(1), stream, + None, Box::new({ let state = Arc::clone(&state); move |_send_tx, cmd| process(Arc::clone(&state), cmd) @@ -248,10 +299,19 @@ impl Client { } async fn rcall(&self, payload: ClientCommand, cb: &RCallback) -> Result { + self.rcall_with_timeout(payload, cb, TIMEOUT).await + } + + async fn rcall_with_timeout( + &self, + payload: ClientCommand, + cb: &RCallback, + timeout: Duration, + ) -> Result { self.stream.send(payload).await?; let (tx, rx) = oneshot::channel(); *cb.lock().await = Some(tx); - time::timeout(TIMEOUT, rx) + time::timeout(timeout, rx) .await .context("timeout")?? .map_err(Error::msg) @@ -384,12 +444,128 @@ impl Client { Ok(()) } + #[inline] + pub async fn select_local_chart(&self, id: impl Into, name: impl Into) -> Result<()> { + self.rcall( + ClientCommand::SelectLocalChart { + id: id.into().try_into()?, + name: name.into().try_into()?, + }, + &self.state.cb_select_local_chart, + ) + .await + } + + #[inline] + pub async fn select_online_chart(&self, id: i32) -> Result<()> { + self.rcall( + ClientCommand::SelectOnlineChart { id }, + &self.state.cb_select_online_chart, + ) + .await + } + + #[inline] + pub async fn send_chart(&self, addr: impl Into, port: u16) -> Result<()> { + self.rcall( + ClientCommand::SendChart { + addr: addr.into(), + port, + }, + &self.state.cb_send_chart, + ) + .await + } + + #[inline] + pub async fn download_ready(&self) -> Result<()> { + self.rcall(ClientCommand::DownloadReady, &self.state.cb_download_ready) + .await + } + + /// 房主取消本地谱面分享(删除服务端缓存、重置所有玩家就绪) + #[inline] + pub async fn cancel_local_chart(&self) -> Result<()> { + self.rcall(ClientCommand::CancelLocalChart, &self.state.cb_cancel_local_chart) + .await + } + + /// 玩家取消已就绪(尚未开始游玩前可取消) + #[inline] + pub async fn cancel_download_ready(&self) -> Result<()> { + self.rcall(ClientCommand::CancelDownloadReady, &self.state.cb_cancel_download_ready) + .await + } + + /// 上传本地谱面包到服务端(经 game 连接,兼容内网穿透) + #[inline] + pub async fn upload_chart(&self, id: impl Into, data: Vec) -> Result<()> { + self.rcall_with_timeout( + ClientCommand::UploadChart { + id: id.into().try_into()?, + data, + }, + &self.state.cb_upload_chart, + TRANSFER_TIMEOUT, + ) + .await + } + + /// 从服务端获取谱面包(经 game 连接) + #[inline] + pub async fn download_chart(&self, id: impl Into) -> Result> { + self.rcall_with_timeout( + ClientCommand::DownloadChart { + id: id.into().try_into()?, + }, + &self.state.cb_download_chart, + TRANSFER_TIMEOUT, + ) + .await + } + + /// 取走所有尚未被上层消费的本地谱面同步事件 + pub fn blocking_take_local_chart_events(&self) -> Vec { + self.state.local_chart_events.blocking_lock().drain(..).collect() + } + #[inline] pub async fn played(&self, id: i32) -> Result<()> { self.rcall(ClientCommand::Played { id }, &self.state.cb_played) .await } + /// 本地谱面成绩直传(无官方 record id,客户端上报真实成绩) + #[inline] + pub async fn played_with_score( + &self, + id: i32, + score: u32, + accuracy: f32, + full_combo: bool, + max_combo: u32, + perfect: u32, + good: u32, + bad: u32, + miss: u32, + ) -> Result<()> { + self.rcall( + ClientCommand::PlayedWithScore { + id, + score, + accuracy, + full_combo, + max_combo, + perfect, + good, + bad, + miss, + }, + &self.state.cb_played, + ) + .await + } + #[inline] pub async fn abort(&self) -> Result<()> { self.rcall(ClientCommand::Abort, &self.state.cb_abort).await @@ -521,5 +697,79 @@ async fn process(state: Arc, cmd: ServerCommand) { ServerCommand::Abort(res) => { cb(&state.cb_abort, res).await; } + + ServerCommand::ChangeLocalChart { local, chart_id } => { + state + .local_chart_events + .lock() + .await + .push_back(LocalChartEvent::ChangeLocalChart { local, chart_id }); + } + ServerCommand::StartServing { chart_id, chart_name } => { + state + .local_chart_events + .lock() + .await + .push_back(LocalChartEvent::StartServing { chart_id, chart_name }); + } + ServerCommand::StartDownload { + host_id, + host_name, + addr, + port, + chart_id, + chart_name, + } => { + state + .local_chart_events + .lock() + .await + .push_back(LocalChartEvent::StartDownload { + host_id, + host_name, + addr, + port, + chart_id, + chart_name, + }); + } + ServerCommand::HostReady => { + state + .local_chart_events + .lock() + .await + .push_back(LocalChartEvent::HostReady); + } + ServerCommand::LocalChartCanceled => { + state + .local_chart_events + .lock() + .await + .push_back(LocalChartEvent::Canceled); + } + ServerCommand::SelectLocalChart(res) => { + cb(&state.cb_select_local_chart, res).await; + } + ServerCommand::SelectOnlineChart(res) => { + cb(&state.cb_select_online_chart, res).await; + } + ServerCommand::SendChart(res) => { + cb(&state.cb_send_chart, res).await; + } + ServerCommand::DownloadReady(res) => { + cb(&state.cb_download_ready, res).await; + } + ServerCommand::CancelLocalChart(res) => { + cb(&state.cb_cancel_local_chart, res).await; + } + ServerCommand::CancelDownloadReady(res) => { + cb(&state.cb_cancel_download_ready, res).await; + } + ServerCommand::UploadChart(res) => { + cb(&state.cb_upload_chart, res).await; + } + ServerCommand::DownloadChart(res) => { + cb(&state.cb_download_chart, res).await; + } } } diff --git a/phira-mp-common/src/command.rs b/phira-mp-common/src/command.rs index fd5512f..752b66a 100644 --- a/phira-mp-common/src/command.rs +++ b/phira-mp-common/src/command.rs @@ -175,6 +175,37 @@ pub enum ClientCommand { CancelReady, Played { id: i32 }, Abort, + + // LocalChart: 房主选择本地谱面分享给房间内玩家(id 为随机 UUID,8-4-4-4-12) + SelectLocalChart { id: Varchar<40>, name: Varchar<64> }, + // LocalChart: 房主改选在线谱面,取消本地谱面分享 + SelectOnlineChart { id: i32 }, + // LocalChart: 房主通知服务端已就绪作为下载服务器,可以开始向玩家提供下载 + SendChart { addr: String, port: u16 }, + // LocalChart: 玩家通知服务端谱面下载完成,可以就绪 + DownloadReady, + + // 本地谱面分享:房主上传谱面包(经 game 连接,兼容内网穿透) + UploadChart { id: Varchar<40>, data: Vec }, + // 本地谱面分享:玩家请求下载谱面包 + DownloadChart { id: Varchar<40> }, + // LocalChart: 房主取消本地谱面分享(删除服务端缓存、重置所有玩家就绪状态) + CancelLocalChart, + // LocalChart: 玩家取消已就绪(尚未开始游玩前可取消) + CancelDownloadReady, + + // 新版客户端直传最终成绩;追加在枚举末尾,保持原版命令编号兼容。 + PlayedWithScore { + id: i32, + score: u32, + accuracy: f32, + full_combo: bool, + max_combo: u32, + perfect: u32, + good: u32, + bad: u32, + miss: u32, + }, } #[derive(Clone, Debug, BinaryData)] @@ -231,6 +262,20 @@ pub enum Message { CycleRoom { cycle: bool, }, + // LocalChart: 房主选择了本地谱面 + SelectLocalChart { + user: i32, + name: String, + id: String, + }, + // LocalChart: 房主通知服务端上传服务器已就绪 + SendChart { + user: i32, + }, + // LocalChart: 玩家下载完成,通知房主 + DownloadReady { + user: i32, + }, } #[derive(Debug, BinaryData, Clone, Copy)] @@ -238,6 +283,8 @@ pub enum RoomState { SelectChart(Option), WaitingForReady, Playing, + // 房主选择本地谱面,房间进入本地分享阶段(谱面 id 通过 ChangeLocalChart 命令下发) + LocalChart, } impl Default for RoomState { @@ -305,4 +352,39 @@ pub enum ServerCommand { CancelReady(SResult<()>), Played(SResult<()>), Abort(SResult<()>), + + // LocalChart: 服务端下发本地谱面分享状态(房间所有人、包括房主都会收到) + ChangeLocalChart { + local: bool, + chart_id: String, + }, + // LocalChart: 服务端通知房主启动本地的下载服务器(房主收到后应开 HTTP 服务器并发 SendChart) + StartServing { + chart_id: String, + chart_name: String, + }, + // LocalChart: 服务端通知各非房主玩家开始从房主下载谱面 + StartDownload { + host_id: i32, + host_name: String, + addr: String, + port: u16, + chart_id: String, + chart_name: String, + }, + // LocalChart: 服务端通知房主所有玩家已下载完成,可以开始游戏 + HostReady, + // LocalChart: 服务端通知所有客户端本地谱面分享已取消(重置就绪状态,仍停留在选谱/分享阶段) + LocalChartCanceled, + // 回执 + SelectLocalChart(SResult<()>), + SelectOnlineChart(SResult<()>), + SendChart(SResult<()>), + DownloadReady(SResult<()>), + CancelLocalChart(SResult<()>), + CancelDownloadReady(SResult<()>), + + // 本地谱面分享:上传回执 / 下载数据 + UploadChart(SResult<()>), + DownloadChart(SResult>), } diff --git a/phira-mp-common/src/lib.rs b/phira-mp-common/src/lib.rs index 5d5692e..5c33442 100644 --- a/phira-mp-common/src/lib.rs +++ b/phira-mp-common/src/lib.rs @@ -48,6 +48,7 @@ where pub async fn new( version: Option, stream: TcpStream, + activity: Option>, mut handler: Box>, R) -> F + Send + Sync>, ) -> Result where @@ -101,6 +102,7 @@ where let recv_task_handle = tokio::spawn({ let send_tx = Arc::clone(&send_tx); + let activity = activity; #[allow(clippy::read_zero_byte_vec)] async move { let mut buffer = Vec::new(); @@ -118,13 +120,25 @@ where bail!("invalid length"); } } - if len > 2 * 1024 * 1024 { + if len > 512 * 1024 * 1024 { bail!("data packet too large"); } let len = len as usize; buffer.resize(len, 0); - read.read_exact(&mut buffer).await?; + // 分块读取,并在读取过程中刷新活动时间戳(心跳), + // 避免大帧(如本地谱面包)传输期间因心跳超时被误判为掉线。 + let mut read_pos = 0; + while read_pos < len { + let n = read.read(&mut buffer[read_pos..]).await?; + if n == 0 { + bail!("eof while reading packet"); + } + read_pos += n; + if let Some(activity) = &activity { + activity(); + } + } trace!("received {} bytes: {buffer:?}", buffer.len()); let payload: R = match decode_packet(&buffer) { diff --git a/phira-mp-server/src/room.rs b/phira-mp-server/src/room.rs index a292c47..5722970 100644 --- a/phira-mp-server/src/room.rs +++ b/phira-mp-server/src/room.rs @@ -26,6 +26,10 @@ pub enum InternalRoomState { results: HashMap, aborted: HashSet, }, + // 房主选择了本地谱面,处于本地分享阶段;started = 已同步完成的玩家 id 集合 + LocalChart { + started: HashSet, + }, } impl InternalRoomState { @@ -34,6 +38,7 @@ impl InternalRoomState { Self::SelectChart => RoomState::SelectChart(chart), Self::WaitForReady { .. } => RoomState::WaitingForReady, Self::Playing { .. } => RoomState::Playing, + Self::LocalChart { .. } => RoomState::LocalChart, } } } @@ -50,6 +55,10 @@ pub struct Room { users: RwLock>>, monitors: RwLock>>, pub chart: RwLock>, + // 正在分享的本地谱面 (UUID id, 名称) + pub local_chart: RwLock>, + // 房主是否已上传本地谱面包到服务端缓存(玩家须等上传完成后才能就绪/下载) + pub chart_uploaded: AtomicBool, } impl Room { @@ -66,6 +75,8 @@ impl Room { users: vec![host].into(), monitors: Vec::new().into(), chart: RwLock::default(), + local_chart: RwLock::default(), + chart_uploaded: AtomicBool::new(false), } } @@ -207,6 +218,14 @@ impl Room { .retain(|it| it.upgrade().is_some_and(|it| it.id != user.id)); if self.check_host(user).await.is_ok() { info!("host disconnected!"); + // 若游戏进行中房主退出,直接结束本局,回到选谱界面, + // 避免因其他成员未完成而卡在 Playing 状态、无法进入下一轮。 + if matches!(*self.state.read().await, InternalRoomState::Playing { .. }) { + info!(room = self.id.to_string(), "host left during playing, ending game"); + *self.local_chart.write().await = None; + *self.state.write().await = InternalRoomState::SelectChart; + self.on_state_change().await; + } let users = self.users().await; if users.is_empty() { info!("room users all disconnected, dropping room"); @@ -233,6 +252,48 @@ impl Room { pub async fn check_all_ready(&self) { let guard = self.state.read().await; match guard.deref() { + InternalRoomState::LocalChart { started } => { + // 本地谱面分享阶段:所有需要下载的玩家(非房主、非 monitor 观察者) + // 就绪后,通知房主并开始游戏。若房间只有房主一人,无需下载,直接视为就绪。 + // 前提:房主必须先上传谱面包到服务端缓存(chart_uploaded)。 + let users = self.users().await; + let all_downloaded = self.chart_uploaded.load(Ordering::SeqCst) + && match self.host.read().await.upgrade() { + Some(host) => { + let hid = host.id; + users + .iter() + .filter(|it| it.id != hid && !it.monitor.load(Ordering::SeqCst)) + .all(|it| started.contains(&it.id)) + } + None => false, + }; + if all_downloaded { + drop(guard); + info!(room = self.id.to_string(), "local chart all downloaded, game start"); + // 通知房主所有玩家已下载完成,可以关闭下载连接 + if let Some(host) = self.host.read().await.upgrade() { + // 游戏即将开始:删除服务端缓存的谱面包,避免占用磁盘空间 + if let Some((chart_id, _)) = self.local_chart.read().await.clone() { + host.server + .chart_cache + .write() + .await + .remove(&chart_id); + info!(room = self.id.to_string(), chart = %chart_id, "cleared chart cache after game start"); + } + host.try_send(ServerCommand::HostReady).await; + } + self.chart_uploaded.store(false, Ordering::SeqCst); + self.send(Message::StartPlaying).await; + self.reset_game_time().await; + *self.state.write().await = InternalRoomState::Playing { + results: HashMap::new(), + aborted: HashSet::new(), + }; + self.on_state_change().await; + } + } InternalRoomState::WaitForReady { started } => { if self .users() @@ -264,6 +325,12 @@ impl Room { self.send(Message::GameEnd).await; // dbg!(2); *self.state.write().await = InternalRoomState::SelectChart; + // 清空本地谱面残留状态并通知所有客户端退出本地谱面分享, + // 避免本地谱面游玩结束后切换到在线谱面时状态错乱(卡在转圈/无法开始)。 + *self.local_chart.write().await = None; + self.chart_uploaded.store(false, Ordering::SeqCst); + self.broadcast(ServerCommand::ChangeLocalChart { local: false, chart_id: String::new() }) + .await; // dbg!(3); if self.is_cycle() { debug!(room = self.id.to_string(), "cycling"); diff --git a/phira-mp-server/src/server.rs b/phira-mp-server/src/server.rs index c67a80d..3ab2b1a 100644 --- a/phira-mp-server/src/server.rs +++ b/phira-mp-server/src/server.rs @@ -47,6 +47,9 @@ pub struct ServerState { pub rooms: SafeMap>, pub lost_con_tx: mpsc::Sender, + + // 本地谱面分享中转缓存:chart_uuid -> zip 字节(房主上传,玩家经 game 连接下载) + pub chart_cache: SafeMap>, } pub struct Server { @@ -70,6 +73,8 @@ impl From for Server { rooms: SafeMap::default(), lost_con_tx, + + chart_cache: SafeMap::default(), }); let lost_con_handle = tokio::spawn({ let state = Arc::clone(&state); diff --git a/phira-mp-server/src/session.rs b/phira-mp-server/src/session.rs index f93fb3b..91b0f99 100644 --- a/phira-mp-server/src/session.rs +++ b/phira-mp-server/src/session.rs @@ -137,10 +137,17 @@ impl Session { let this = Arc::new(OnceCell::>::new()); let this_inited = Arc::new(Notify::new()); let (tx, rx) = oneshot::channel::>(); - let last_recv: Arc> = Arc::new(Mutex::new(Instant::now())); + let last_recv: Arc> = Arc::new(std::sync::Mutex::new(Instant::now())); + // 大帧(如本地谱面包上传)接收期间也要持续刷新心跳时间戳, + // 否则一次较慢的大帧传输会触发心跳超时,被误判为掉线并销毁房间。 + let activity_last_recv = Arc::clone(&last_recv); + let activity: Option> = Some(Arc::new(move || { + *activity_last_recv.lock().unwrap() = Instant::now(); + })); let stream = Stream::::new( None, stream, + activity, Box::new({ let this = Arc::clone(&this); let this_inited = Arc::clone(&this_inited); @@ -158,7 +165,7 @@ impl Session { let waiting_for_authenticate = Arc::clone(&waiting_for_authenticate); let panicked = Arc::clone(&panicked); async move { - *last_recv.lock().await = Instant::now(); + *last_recv.lock().unwrap() = Instant::now(); if panicked.load(Ordering::SeqCst) { return; } @@ -283,10 +290,10 @@ impl Session { let last_recv = Arc::clone(&last_recv); async move { loop { - let recv = *last_recv.lock().await; + let recv = *last_recv.lock().unwrap(); time::sleep_until((recv + HEARTBEAT_DISCONNECT_TIMEOUT).into()).await; - if *last_recv.lock().await + HEARTBEAT_DISCONNECT_TIMEOUT > Instant::now() { + if *last_recv.lock().unwrap() + HEARTBEAT_DISCONNECT_TIMEOUT > Instant::now() { continue; } @@ -333,6 +340,61 @@ impl Drop for Session { } } +async fn handle_played_score( + user: Arc, + id: i32, + score: u32, + accuracy: f32, + full_combo: bool, + max_combo: u32, + perfect: u32, + good: u32, + bad: u32, + miss: u32, +) -> Result<()> { + let room = user + .room + .read() + .await + .as_ref() + .map(Arc::clone) + .ok_or_else(|| anyhow!("no room"))?; + let record = Record { + id, + player: user.id, + score: score as i32, + perfect: perfect as i32, + good: good as i32, + bad: bad as i32, + miss: miss as i32, + max_combo: max_combo as i32, + accuracy, + full_combo, + std: 0., + std_score: 0., + }; + debug!(room = room.id.to_string(), user = user.id, "user played: {record:?}"); + room.send(Message::Played { + user: user.id, + score: record.score, + accuracy: record.accuracy, + full_combo: record.full_combo, + }) + .await; + let mut guard = room.state.write().await; + if let InternalRoomState::Playing { results, aborted } = guard.deref_mut() { + if aborted.contains(&user.id) { + bail!("aborted"); + } + if results.insert(user.id, record).is_some() { + bail!("already uploaded"); + } + drop(guard); + room.check_all_ready().await; + } + Ok(()) +} + async fn process(user: Arc, cmd: ClientCommand) -> Option { #[inline] fn err_to_str(result: Result) -> Result { @@ -593,8 +655,28 @@ async fn process(user: Arc, cmd: ClientCommand) -> Option { ClientCommand::RequestStart => { let res: Result<()> = async move { - get_room!(room, InternalRoomState::SelectChart); + get_room!(room); room.check_host(&user).await?; + // 本地谱面分享阶段:房主点开始 -> 通知房主启动下载服务器 + if matches!(*room.state.read().await, InternalRoomState::LocalChart { .. }) { + let (chart_id, chart_name) = { + let guard = room.local_chart.read().await; + let (id, name) = + guard.clone().ok_or_else(|| anyhow!(tl!("start-no-chart-selected")))?; + (id, name) + }; + debug!(room = room.id.to_string(), "local chart request start, host will serve"); + user.try_send(ServerCommand::StartServing { chart_id, chart_name }) + .await; + return Ok(()); + } + // 校验必须在 SelectChart 状态 + { + let state = room.state.read().await; + if !matches!(*state, InternalRoomState::SelectChart) { + bail!("invalid state"); + } + } if room.chart.read().await.is_none() { bail!(tl!("start-no-chart-selected")); } @@ -651,42 +733,289 @@ async fn process(user: Arc, cmd: ClientCommand) -> Option { Some(ServerCommand::CancelReady(err_to_str(res))) } ClientCommand::Played { id } => { + let user_id = user.id; + // 无官方 record id(-1)时没有可回源的成绩,直接返回成功,不请求 /record/-1。 + if id == -1 { + return Some(ServerCommand::Played(Ok(()))); + } + tokio::spawn(async move { + let fetched: Result = async { + let res: Record = reqwest::get(format!("{HOST}/record/{id}")) + .await? + .error_for_status()? + .json() + .await?; + if res.player != user_id { + bail!("invalid record"); + } + Ok(res) + } + .await; + match fetched { + Ok(record) => { + let _ = handle_played_score( + user, + record.id, + record.score.max(0) as u32, + record.accuracy, + record.full_combo, + record.max_combo.max(0) as u32, + record.perfect.max(0) as u32, + record.good.max(0) as u32, + record.bad.max(0) as u32, + record.miss.max(0) as u32, + ) + .await; + } + Err(err) => { + warn!(user = user_id, record = id, "failed to fetch official record: {err:?}"); + } + } + }); + Some(ServerCommand::Played(Ok(()))) + } + + // 房主选择本地谱面,房间进入 LocalChart 分享阶段 + ClientCommand::SelectLocalChart { id, name } => { let res: Result<()> = async move { - get_room!(room); - let res: Record = reqwest::get(format!("{HOST}/record/{id}")) - .await? - .error_for_status()? - .json() - .await?; - if res.player != user.id { - bail!("invalid record"); - } - debug!( + get_room!(room, InternalRoomState::SelectChart); + room.check_host(&user).await?; + let id = id.into_inner(); + let name = name.into_inner(); + info!( room = room.id.to_string(), user = user.id, - "user played: {res:?}" + chart = %id, + "host selected local chart" ); - room.send(Message::Played { + // 记录本地谱面 (UUID id, name) + *room.local_chart.write().await = Some((id.clone(), name.clone())); + // 房间进入 LocalChart 状态 + *room.state.write().await = InternalRoomState::LocalChart { + started: HashSet::new(), + }; + // 广播给房间内所有人:进入本地谱面分享 + room.broadcast(ServerCommand::ChangeLocalChart { local: true, chart_id: id.clone() }) + .await; + room.send(Message::SelectLocalChart { user: user.id, - score: res.score, - accuracy: res.accuracy, - full_combo: res.full_combo, + name, + id, }) .await; - let mut guard = room.state.write().await; - if let InternalRoomState::Playing { results, aborted } = guard.deref_mut() { - if aborted.contains(&user.id) { - bail!("aborted"); + room.on_state_change().await; + Ok(()) + } + .await; + Some(ServerCommand::SelectLocalChart(err_to_str(res))) + } + + // 房主改选在线谱面,取消本地谱面分享 + ClientCommand::SelectOnlineChart { id } => { + let res: Result<()> = async move { + get_room!(room); + room.check_host(&user).await?; + // 若当前处于本地谱面分享状态,先取消 + if matches!(*room.state.read().await, InternalRoomState::LocalChart { .. }) { + info!(room = room.id.to_string(), user = user.id, "cancel local chart sharing"); + *room.local_chart.write().await = None; + room.broadcast(ServerCommand::ChangeLocalChart { local: false, chart_id: String::new() }) + .await; + *room.state.write().await = InternalRoomState::SelectChart; + } + // 校验必须在 SelectChart 状态(取消后即是) + { + let state = room.state.read().await; + if !matches!(*state, InternalRoomState::SelectChart) { + bail!(tl!("chart-select-not-now")); } - if results.insert(user.id, res).is_some() { - bail!("already uploaded"); + } + let span = debug_span!( + "select online chart", + user = user.id, + room = room.id.to_string(), + chart = id, + ); + async move { + trace!("fetch"); + let res: Chart = reqwest::get(format!("{HOST}/chart/{id}")) + .await? + .error_for_status()? + .json() + .await?; + debug!("chart is {res:?}"); + room.send(Message::SelectChart { + user: user.id, + name: res.name.clone(), + id: res.id, + }) + .await; + *room.chart.write().await = Some(res); + room.on_state_change().await; + Ok(()) + } + .instrument(span) + .await + } + .await; + Some(ServerCommand::SelectOnlineChart(err_to_str(res))) + } + + // 房主作为下载服务器就绪,通知玩家开始从服务端下载 + ClientCommand::SendChart { .. } => { + let res: Result<()> = async move { + get_room!(room); + room.check_host(&user).await?; + let (chart_id, chart_name) = { + let guard = room.local_chart.read().await; + guard + .clone() + .ok_or_else(|| anyhow!(tl!("start-no-chart-selected")))? + }; + info!( + room = room.id.to_string(), + user = user.id, + chart = %chart_id, + "host is sharing local chart via server relay" + ); + room.send(Message::SendChart { user: user.id }).await; + // 服务端中转:把下载信息分发给所有需要下载的玩家(非房主、非 monitor 观察者)。 + // 谱面包经 game 连接(DownloadChart 命令)从服务端下载,无需房主直连地址。 + let host_name = user.name.clone(); + for player in room.users().await { + if player.id == user.id || player.monitor.load(Ordering::SeqCst) { + continue; } + player + .try_send(ServerCommand::StartDownload { + host_id: user.id, + host_name: host_name.clone(), + addr: String::new(), + port: 0, + chart_id: chart_id.clone(), + chart_name: chart_name.clone(), + }) + .await; + } + // 触发就绪判定:若无其他需要下载的玩家(如房间只有房主),立即通知房主并开始游戏 + room.check_all_ready().await; + Ok(()) + } + .await; + Some(ServerCommand::SendChart(err_to_str(res))) + } + + // 玩家下载完成,标记就绪 + ClientCommand::DownloadReady => { + let res: Result<()> = async move { + get_room!(room); + // 房主尚未上传完谱面时,禁止其他玩家提前就绪 + if !room.chart_uploaded.load(Ordering::SeqCst) { + bail!(tl!("chart-not-uploaded-yet")); + } + let mut guard = room.state.write().await; + if let InternalRoomState::LocalChart { started } = guard.deref_mut() { + if !started.insert(user.id) { + bail!("already ready"); + } + info!(room = room.id.to_string(), user = user.id, "local chart downloaded"); + room.send(Message::DownloadReady { user: user.id }).await; drop(guard); room.check_all_ready().await; } Ok(()) } .await; + Some(ServerCommand::DownloadReady(err_to_str(res))) + } + + // 本地谱面分享:房主上传谱面包到服务端中转(经 game 连接,兼容内网穿透) + ClientCommand::UploadChart { id, data } => { + let res: Result<()> = async move { + let id = id.into_inner(); + let size = data.len(); + user.server.chart_cache.write().await.insert(id.clone(), data); + // 标记该房间房主已上传完成,玩家此后才可就绪/下载 + if let Some(room) = user.room.read().await.as_ref().map(Arc::clone) { + room.chart_uploaded.store(true, Ordering::SeqCst); + } + info!(user = user.id, chart = %id, size, "host uploaded chart"); + Ok(()) + } + .await; + Some(ServerCommand::UploadChart(err_to_str(res))) + } + + // 本地谱面分享:玩家从服务端获取谱面包 + ClientCommand::DownloadChart { id } => { + let res: Result> = async move { + let id = id.into_inner(); + user.server + .chart_cache + .read() + .await + .get(&id) + .cloned() + .ok_or_else(|| anyhow!("chart not found: {id}")) + } + .await; + Some(ServerCommand::DownloadChart(err_to_str(res))) + } + + // 房主取消本地谱面分享:删除服务端缓存、重置所有玩家就绪状态 + ClientCommand::CancelLocalChart => { + let res: Result<()> = async move { + get_room!(room); + room.check_host(&user).await?; + info!(room = room.id.to_string(), user = user.id, "host canceled local chart sharing"); + if let Some((chart_id, _)) = room.local_chart.read().await.clone() { + user.server.chart_cache.write().await.remove(&chart_id); + info!(room = room.id.to_string(), chart = %chart_id, "cleared chart cache on cancel"); + } + room.chart_uploaded.store(false, Ordering::SeqCst); + // 重置 LocalChart 就绪集合,让所有玩家可以重新准备 + if let InternalRoomState::LocalChart { started } = &mut *room.state.write().await { + started.clear(); + } + // 广播取消事件,让所有客户端重置就绪/开始按钮状态 + room.broadcast(ServerCommand::LocalChartCanceled).await; + room.on_state_change().await; + Ok(()) + } + .await; + Some(ServerCommand::CancelLocalChart(err_to_str(res))) + } + + // 玩家取消已就绪(尚未开始游玩前可取消) + ClientCommand::CancelDownloadReady => { + let res: Result<()> = async move { + get_room!(room); + let mut guard = room.state.write().await; + if let InternalRoomState::LocalChart { started } = guard.deref_mut() { + started.remove(&user.id); + info!(room = room.id.to_string(), user = user.id, "player canceled local chart ready"); + } + Ok(()) + } + .await; + Some(ServerCommand::CancelDownloadReady(err_to_str(res))) + } + + // 新版客户端直传最终成绩(本地谱面无官方 record id,需客户端上报真实成绩) + ClientCommand::PlayedWithScore { id, score, accuracy, full_combo, max_combo, perfect, good, bad, miss } => { + let res = handle_played_score( + user, + id, + score, + accuracy, + full_combo, + max_combo, + perfect, + good, + bad, + miss, + ) + .await; Some(ServerCommand::Played(err_to_str(res))) } ClientCommand::Abort => {