bevygap_client_plugin/
lib.rs

1use base64::prelude::*;
2use bevy::prelude::*;
3use bevy_nfws::prelude::*;
4use bevygap_shared::protocol::*;
5use lightyear::prelude::{client::*, *};
6use std::net::SocketAddr;
7
8pub mod prelude {
9    pub use super::traits::*;
10    pub use super::BevygapClientConfig;
11    pub use super::BevygapClientPlugin;
12    pub use super::BevygapClientState;
13}
14mod traits;
15
16#[derive(States, Debug, Clone, Default, Eq, PartialEq, Hash)]
17pub enum BevygapClientState {
18    #[default]
19    Dormant,
20    /// Entering this state triggers a "want to play" request to the matchmaker
21    Request,
22    /// The request has been sent, awaiting a response
23    AwaitingResponse(String),
24    /// Got a good response from the matchmaker, ready to connect to the gameserver
25    ReadyToConnect,
26    /// We triggered a connection attempt.
27    Finished,
28    /// The request failed
29    Error(u16, String),
30}
31
32impl BevygapClientState {
33    // run condition alternative to in_state(Enum(_with_param_))
34    // since in_state doesn't support enum variants with parameters
35    fn pending_state() -> impl FnMut(Option<Res<State<BevygapClientState>>>) -> bool + Clone {
36        move |current_state: Option<Res<State<BevygapClientState>>>| match current_state {
37            Some(current_state) => {
38                matches!(current_state.get(), BevygapClientState::AwaitingResponse(_))
39            }
40            _ => false,
41        }
42    }
43}
44
45/// Game-specific configuration.
46#[derive(Resource, Debug, Clone)]
47pub struct BevygapClientConfig {
48    /// The websocket endpoint for the matchmaker, eg:
49    /// ws://localhost:3000/matchmaker/ws
50    pub matchmaker_url: String,
51    /// If set, the client will pass this to the matchmaker, overriding the usual client IP detection.
52    /// This is passed to Edgegap when making the Session.
53    pub fake_client_ip: Option<String>,
54    /// The name of the game, used in the matchmaker request.
55    pub game_name: String,
56    /// The version of the game, used in the matchmaker request.
57    pub game_version: String,
58}
59
60impl Default for BevygapClientConfig {
61    fn default() -> Self {
62        Self {
63            matchmaker_url: "ws://localhost:3000/matchmaker/ws".to_string(),
64            fake_client_ip: None,
65            game_name: "bevygap-spaceships".to_string(),
66            game_version: "1".to_string(),
67        }
68    }
69}
70
71pub struct BevygapClientPlugin;
72
73impl Plugin for BevygapClientPlugin {
74    fn build(&self, app: &mut App) {
75        app.add_plugins(NfwsPlugin);
76        app.init_resource::<BevygapClientConfig>();
77        app.init_state::<BevygapClientState>();
78
79        app.add_systems(OnEnter(BevygapClientState::Request), request_token);
80
81        app.add_systems(
82            Update,
83            handle_matchmaker_response.run_if(BevygapClientState::pending_state()),
84        );
85
86        app.add_systems(OnEnter(BevygapClientState::ReadyToConnect), connect_client);
87    }
88}
89
90fn request_token(
91    mut next_state: ResMut<NextState<BevygapClientState>>,
92    config: Res<BevygapClientConfig>,
93    mut commands: Commands,
94) {
95    // TODO check if mm url is wss:// but matchmaker-tls not enabled, and on native, issue warning.
96    // TODO issue warning if mm url starts http instead of ws. MM is ws!
97    info!(
98        "Initiating matchmaker websocket connection: {}",
99        config.matchmaker_url
100    );
101    commands.spawn(NfwsHandle::new(config.matchmaker_url.clone()));
102
103    next_state.set(BevygapClientState::AwaitingResponse(
104        "Requesting ...".to_string(),
105    ));
106}
107
108fn handle_matchmaker_response(
109    mut q: Query<(Entity, &mut NfwsHandle)>,
110    mut commands: Commands,
111    mut client_config: ResMut<ClientConfig>,
112    mut next_state: ResMut<NextState<BevygapClientState>>,
113    config: Res<BevygapClientConfig>,
114) {
115    for (entity, mut nfws) in q.iter_mut() {
116        match nfws.next_event() {
117            NfwsPollResult::Closed => {
118                info!("EV None = closed, despawning");
119                commands.entity(entity).despawn();
120                continue;
121            }
122            NfwsPollResult::Empty => continue,
123            NfwsPollResult::Event(rec) => {
124                info!("EV: {rec:?}");
125                match rec {
126                    NfwsEvent::Connecting => {
127                        info!("Matchmaker: {rec:?}");
128                    }
129                    NfwsEvent::Connected => {
130                        info!("Matchmaker: {rec:?}");
131                        let req = RequestSession {
132                            client_ip: config.fake_client_ip.clone(),
133                            game: config.game_name.clone(),
134                            version: config.game_version.clone(),
135                        };
136                        let payload = serde_json::to_string(&req).unwrap();
137                        info!("Sending payload: {payload}");
138                        nfws.send_text(payload);
139                    }
140                    NfwsEvent::Error(nfws_err) => match nfws_err {
141                        NfwsErr::Connecting => next_state.set(BevygapClientState::Error(
142                            0,
143                            "Can't connect to matchmaker".to_string(),
144                        )),
145                        NfwsErr::Receiving(msg) => next_state.set(BevygapClientState::Error(
146                            0,
147                            format!("Rcv error from matchmaker: {msg}"),
148                        )),
149                        NfwsErr::Sending(msg) => next_state.set(BevygapClientState::Error(
150                            0,
151                            format!("Send error to matchmaker: {msg}"),
152                        )),
153                    },
154                    NfwsEvent::Closed(frame) => {
155                        info!("Matchmaker connection closed: {frame:?}");
156                    }
157                    NfwsEvent::BinaryMessage(_vec) => {
158                        warn!("Matchmaker: binary msg received, unhandled.");
159                    }
160                    NfwsEvent::TextMessage(msg) => {
161                        let Ok(feedback) =
162                            serde_json::from_slice::<SessionRequestFeedback>(msg.as_bytes())
163                        else {
164                            warn!("Unhandled msg type from matchmaker: {msg:?}");
165                            warn!("Despawning client entity");
166                            next_state.set(BevygapClientState::Error(
167                                0,
168                                "Unhandled response from matchmaker".to_string(),
169                            ));
170                            commands.entity(entity).despawn();
171                            continue;
172                        };
173                        info!(">>> {feedback:?}");
174                        match feedback {
175                            SessionRequestFeedback::Acknowledged => {
176                                next_state.set(BevygapClientState::AwaitingResponse(
177                                    "Request acknowledged".to_string(),
178                                ))
179                            }
180                            SessionRequestFeedback::SessionRequestAccepted(sess_id) => next_state
181                                .set(BevygapClientState::AwaitingResponse(format!(
182                                    "Session created: {sess_id}"
183                                ))),
184                            SessionRequestFeedback::ProgressReport(prog_msg) => {
185                                next_state.set(BevygapClientState::AwaitingResponse(format!(
186                                    "Progress: {prog_msg}"
187                                )))
188                            }
189                            SessionRequestFeedback::Error(err_code, err_msg) => {
190                                next_state.set(BevygapClientState::Error(err_code, err_msg))
191                            }
192                            SessionRequestFeedback::SessionReady {
193                                token,
194                                ip,
195                                port,
196                                cert_digest,
197                            } => {
198                                let cert_digest = cert_digest.replace(':', "");
199                                info!("Using cert digest {cert_digest}");
200                                let tok_bytes = BASE64_STANDARD.decode(&token).unwrap();
201                                assert_eq!(
202                                    tok_bytes.len(),
203                                    2048,
204                                    "ConnectTokens should be 2048 bytes exactly"
205                                );
206                                let connect_token =
207                                    ConnectToken::try_from_bytes(tok_bytes.as_slice()).unwrap();
208
209                                // TODO be defensive here
210                                let server_addr: SocketAddr =
211                                    format!("{ip}:{port}").parse().expect(
212                                        "invalid gameserver addr/port from matchmaker response?",
213                                    );
214
215                                info!("Got matchmaker response, game server: {server_addr:?}");
216
217                                if let NetConfig::Netcode { auth, io, .. } = &mut client_config.net
218                                {
219                                    info!("Setting Netcode connect token and server addr");
220                                    *auth = Authentication::Token(connect_token);
221                                    // inject gameserver address and port into lightyear client transport
222                                    // (preserves existing client_addr if it was already set)
223                                    let client_addr = match &mut io.transport {
224                                        client::ClientTransport::WebTransportClient {
225                                            client_addr,
226                                            ..
227                                        } => client_addr,
228                                        _ => panic!("Unsupported transport: {:?}", io.transport),
229                                    };
230                                    io.transport = client::ClientTransport::WebTransportClient {
231                                        client_addr: *client_addr,
232                                        server_addr,
233                                        #[cfg(target_family = "wasm")]
234                                        certificate_digest: cert_digest,
235                                    };
236                                } else {
237                                    panic!("Unsupported netconfig, only supports Netcode for now.");
238                                }
239                                next_state.set(BevygapClientState::ReadyToConnect);
240                            }
241                        }
242                    }
243                }
244            }
245        }
246    }
247}
248
249fn connect_client(mut commands: Commands, mut next_state: ResMut<NextState<BevygapClientState>>) {
250    info!("Connecting to server...");
251    commands.connect_client();
252    next_state.set(BevygapClientState::Finished);
253}