Skip to main content

signal_fish_client/
event.rs

1//! High-level events emitted by the Signal Fish client.
2//!
3//! [`SignalFishEvent`] provides a 1:1 mapping from every [`ServerMessage`] variant
4//! plus two synthetic events (`Connected` and `Disconnected`) that originate from
5//! the transport layer rather than the server.
6//!
7//! Boxed payload types ([`RoomJoinedPayload`], [`ReconnectedPayload`],
8//! [`SpectatorJoinedPayload`]) are flattened into inline fields so callers can
9//! pattern-match directly without an extra dereference.
10//!
11//! [`ServerMessage`]: crate::protocol::ServerMessage
12//! [`RoomJoinedPayload`]: crate::protocol::RoomJoinedPayload
13//! [`ReconnectedPayload`]: crate::protocol::ReconnectedPayload
14//! [`SpectatorJoinedPayload`]: crate::protocol::SpectatorJoinedPayload
15
16use crate::error_codes::ErrorCode;
17use crate::protocol::{
18    GameDataEncoding, LobbyState, PeerConnectionInfo, PlayerId, PlayerInfo, ProtocolInfoPayload,
19    RateLimitInfo, RoomId, ServerMessage, SpectatorInfo, SpectatorStateChangeReason,
20};
21
22/// Events emitted by the Signal Fish client.
23///
24/// Each variant corresponds to either a [`ServerMessage`] received from the
25/// signaling server or a synthetic event generated by the transport layer.
26///
27/// # Synthetic events
28///
29/// | Variant | Origin |
30/// |---|---|
31/// | [`Connected`](Self::Connected) | Transport layer opened successfully |
32/// | [`Disconnected`](Self::Disconnected) | Transport layer closed or errored |
33///
34/// # Example
35///
36/// ```text
37/// // Assuming `events` is an async receiver of SignalFishEvent:
38/// match event {
39///     SignalFishEvent::RoomJoined { room_code, current_players, .. } => { /* … */ }
40///     SignalFishEvent::PlayerJoined { player } => { /* … */ }
41///     SignalFishEvent::Disconnected { reason } => { /* … */ }
42///     _ => {}
43/// }
44/// ```
45#[derive(Debug, Clone)]
46pub enum SignalFishEvent {
47    // ── Synthetic events ────────────────────────────────────────────
48    /// The client has started and will begin communicating with the server.
49    ///
50    /// This is a **synthetic event** — it is not triggered by a server message.
51    ///
52    /// - **`SignalFishClient`** (async): emitted at the start of the transport
53    ///   loop, after the transport has already been connected via
54    ///   `.connect().await`.
55    /// - **`SignalFishPollingClient`**: emitted once
56    ///   [`Transport::is_ready()`](crate::Transport::is_ready) returns `true`
57    ///   during a [`poll()`](crate::SignalFishPollingClient::poll) cycle. For
58    ///   transports that are already connected at construction time, this is
59    ///   the first `poll()` call. For transports with asynchronous handshakes
60    ///   (e.g., `EmscriptenWebSocketTransport`), `Connected` is deferred
61    ///   until the handshake completes.
62    Connected,
63
64    /// The transport connection was closed.
65    Disconnected {
66        /// Human-readable reason for the disconnection, if available.
67        reason: Option<String>,
68    },
69
70    // ── Authentication ──────────────────────────────────────────────
71    /// Authentication succeeded.
72    Authenticated {
73        /// Application name confirmed by the server.
74        app_name: String,
75        /// Organization the app belongs to, if any.
76        organization: Option<String>,
77        /// Rate limits enforced for this application.
78        rate_limits: RateLimitInfo,
79    },
80
81    /// SDK/protocol compatibility details advertised after authentication.
82    ///
83    /// Wrapped as a single payload rather than flattened because most fields
84    /// are optional configuration details that callers typically access as a group.
85    ProtocolInfo(ProtocolInfoPayload),
86
87    /// Authentication failed.
88    AuthenticationError {
89        /// Human-readable error description.
90        error: String,
91        /// Structured error code for programmatic handling.
92        error_code: ErrorCode,
93    },
94
95    // ── Room lifecycle ──────────────────────────────────────────────
96    /// Successfully joined a room. Fields are flattened from [`RoomJoinedPayload`].
97    ///
98    /// [`RoomJoinedPayload`]: crate::protocol::RoomJoinedPayload
99    RoomJoined {
100        /// Unique room identifier.
101        room_id: RoomId,
102        /// Human-readable room code.
103        room_code: String,
104        /// The local player's identifier.
105        player_id: PlayerId,
106        /// Name of the game this room is for.
107        game_name: String,
108        /// Maximum number of players allowed.
109        max_players: u8,
110        /// Whether the room supports authority delegation.
111        supports_authority: bool,
112        /// Players already present in the room.
113        current_players: Vec<PlayerInfo>,
114        /// Whether the local player is the authority.
115        is_authority: bool,
116        /// Current lobby readiness state.
117        lobby_state: LobbyState,
118        /// Players that have signaled readiness.
119        ready_players: Vec<PlayerId>,
120        /// Relay transport type label (e.g. `"auto"`, `"tcp"`).
121        relay_type: String,
122        /// Spectators currently watching.
123        current_spectators: Vec<SpectatorInfo>,
124    },
125
126    /// Failed to join a room.
127    RoomJoinFailed {
128        /// Human-readable failure reason.
129        reason: String,
130        /// Structured error code, if provided.
131        error_code: Option<ErrorCode>,
132    },
133
134    /// Successfully left the current room.
135    RoomLeft,
136
137    // ── Player presence ─────────────────────────────────────────────
138    /// Another player joined the room.
139    PlayerJoined {
140        /// Information about the new player.
141        player: PlayerInfo,
142    },
143
144    /// Another player left the room.
145    PlayerLeft {
146        /// Identifier of the player who left.
147        player_id: PlayerId,
148    },
149
150    // ── Game data ───────────────────────────────────────────────────
151    /// JSON game data received from another player.
152    GameData {
153        /// Identifier of the sending player.
154        from_player: PlayerId,
155        /// Arbitrary JSON payload.
156        data: serde_json::Value,
157    },
158
159    /// Binary game data received from another player.
160    GameDataBinary {
161        /// Identifier of the sending player.
162        from_player: PlayerId,
163        /// Encoding format of the binary payload.
164        encoding: GameDataEncoding,
165        /// Raw binary payload.
166        payload: Vec<u8>,
167    },
168
169    // ── Authority ───────────────────────────────────────────────────
170    /// The room's authority assignment changed.
171    AuthorityChanged {
172        /// The player who now holds authority, if any.
173        authority_player: Option<PlayerId>,
174        /// Whether the local player is now the authority.
175        you_are_authority: bool,
176    },
177
178    /// Response to an authority request.
179    AuthorityResponse {
180        /// Whether the request was granted.
181        granted: bool,
182        /// Human-readable reason if the request was denied.
183        reason: Option<String>,
184        /// Structured error code, if provided.
185        error_code: Option<ErrorCode>,
186    },
187
188    // ── Lobby ───────────────────────────────────────────────────────
189    /// The lobby readiness state changed.
190    LobbyStateChanged {
191        /// New lobby state.
192        lobby_state: LobbyState,
193        /// Players that have signaled readiness.
194        ready_players: Vec<PlayerId>,
195        /// Whether all players are ready.
196        all_ready: bool,
197    },
198
199    /// The game is starting with peer connection information.
200    GameStarting {
201        /// Connection details for every peer.
202        peer_connections: Vec<PeerConnectionInfo>,
203    },
204
205    // ── Heartbeat ───────────────────────────────────────────────────
206    /// Pong response to a ping.
207    Pong,
208
209    // ── Reconnection ────────────────────────────────────────────────
210    /// Reconnection succeeded. Fields are flattened from [`ReconnectedPayload`].
211    ///
212    /// [`ReconnectedPayload`]: crate::protocol::ReconnectedPayload
213    Reconnected {
214        /// Unique room identifier.
215        room_id: RoomId,
216        /// Human-readable room code.
217        room_code: String,
218        /// The local player's identifier.
219        player_id: PlayerId,
220        /// Name of the game this room is for.
221        game_name: String,
222        /// Maximum number of players allowed.
223        max_players: u8,
224        /// Whether the room supports authority delegation.
225        supports_authority: bool,
226        /// Players currently in the room.
227        current_players: Vec<PlayerInfo>,
228        /// Whether the local player is the authority.
229        is_authority: bool,
230        /// Current lobby readiness state.
231        lobby_state: LobbyState,
232        /// Players that have signaled readiness.
233        ready_players: Vec<PlayerId>,
234        /// Relay transport type label.
235        relay_type: String,
236        /// Spectators currently watching.
237        current_spectators: Vec<SpectatorInfo>,
238        /// Events that occurred while the client was disconnected.
239        missed_events: Vec<SignalFishEvent>,
240    },
241
242    /// Reconnection failed.
243    ReconnectionFailed {
244        /// Human-readable failure reason.
245        reason: String,
246        /// Structured error code.
247        error_code: ErrorCode,
248    },
249
250    /// Another player reconnected to the room.
251    PlayerReconnected {
252        /// Identifier of the player who reconnected.
253        player_id: PlayerId,
254    },
255
256    // ── Spectator ───────────────────────────────────────────────────
257    /// Successfully joined a room as a spectator.
258    /// Fields are flattened from [`SpectatorJoinedPayload`].
259    ///
260    /// [`SpectatorJoinedPayload`]: crate::protocol::SpectatorJoinedPayload
261    SpectatorJoined {
262        /// Unique room identifier.
263        room_id: RoomId,
264        /// Human-readable room code.
265        room_code: String,
266        /// The local spectator's identifier.
267        spectator_id: PlayerId,
268        /// Name of the game this room is for.
269        game_name: String,
270        /// Players currently in the room.
271        current_players: Vec<PlayerInfo>,
272        /// Spectators currently watching.
273        current_spectators: Vec<SpectatorInfo>,
274        /// Current lobby readiness state.
275        lobby_state: LobbyState,
276        /// Reason the spectator state changed, if applicable.
277        reason: Option<SpectatorStateChangeReason>,
278    },
279
280    /// Failed to join as a spectator.
281    SpectatorJoinFailed {
282        /// Human-readable failure reason.
283        reason: String,
284        /// Structured error code, if provided.
285        error_code: Option<ErrorCode>,
286    },
287
288    /// Successfully left spectator mode.
289    SpectatorLeft {
290        /// Room identifier, if available.
291        room_id: Option<RoomId>,
292        /// Room code, if available.
293        room_code: Option<String>,
294        /// Reason for leaving, if available.
295        reason: Option<SpectatorStateChangeReason>,
296        /// Remaining spectators in the room.
297        current_spectators: Vec<SpectatorInfo>,
298    },
299
300    /// Another spectator joined the room.
301    NewSpectatorJoined {
302        /// Information about the new spectator.
303        spectator: SpectatorInfo,
304        /// All spectators currently watching.
305        current_spectators: Vec<SpectatorInfo>,
306        /// Reason for the state change, if available.
307        reason: Option<SpectatorStateChangeReason>,
308    },
309
310    /// Another spectator disconnected from the room.
311    SpectatorDisconnected {
312        /// Identifier of the spectator who disconnected.
313        spectator_id: PlayerId,
314        /// Reason for disconnection, if available.
315        reason: Option<SpectatorStateChangeReason>,
316        /// Remaining spectators in the room.
317        current_spectators: Vec<SpectatorInfo>,
318    },
319
320    // ── Errors ──────────────────────────────────────────────────────
321    /// A generic server error.
322    Error {
323        /// Human-readable error message.
324        message: String,
325        /// Structured error code, if provided.
326        error_code: Option<ErrorCode>,
327    },
328}
329
330// ── Conversion ──────────────────────────────────────────────────────
331
332impl From<ServerMessage> for SignalFishEvent {
333    fn from(msg: ServerMessage) -> Self {
334        match msg {
335            ServerMessage::Authenticated {
336                app_name,
337                organization,
338                rate_limits,
339            } => Self::Authenticated {
340                app_name,
341                organization,
342                rate_limits,
343            },
344            ServerMessage::ProtocolInfo(payload) => Self::ProtocolInfo(payload),
345            ServerMessage::AuthenticationError { error, error_code } => {
346                Self::AuthenticationError { error, error_code }
347            }
348            ServerMessage::RoomJoined(payload) => {
349                let p = *payload;
350                Self::RoomJoined {
351                    room_id: p.room_id,
352                    room_code: p.room_code,
353                    player_id: p.player_id,
354                    game_name: p.game_name,
355                    max_players: p.max_players,
356                    supports_authority: p.supports_authority,
357                    current_players: p.current_players,
358                    is_authority: p.is_authority,
359                    lobby_state: p.lobby_state,
360                    ready_players: p.ready_players,
361                    relay_type: p.relay_type,
362                    current_spectators: p.current_spectators,
363                }
364            }
365            ServerMessage::RoomJoinFailed { reason, error_code } => {
366                Self::RoomJoinFailed { reason, error_code }
367            }
368            ServerMessage::RoomLeft => Self::RoomLeft,
369            ServerMessage::PlayerJoined { player } => Self::PlayerJoined { player },
370            ServerMessage::PlayerLeft { player_id } => Self::PlayerLeft { player_id },
371            ServerMessage::GameData { from_player, data } => Self::GameData { from_player, data },
372            ServerMessage::GameDataBinary {
373                from_player,
374                encoding,
375                payload,
376            } => Self::GameDataBinary {
377                from_player,
378                encoding,
379                payload,
380            },
381            ServerMessage::AuthorityChanged {
382                authority_player,
383                you_are_authority,
384            } => Self::AuthorityChanged {
385                authority_player,
386                you_are_authority,
387            },
388            ServerMessage::AuthorityResponse {
389                granted,
390                reason,
391                error_code,
392            } => Self::AuthorityResponse {
393                granted,
394                reason,
395                error_code,
396            },
397            ServerMessage::LobbyStateChanged {
398                lobby_state,
399                ready_players,
400                all_ready,
401            } => Self::LobbyStateChanged {
402                lobby_state,
403                ready_players,
404                all_ready,
405            },
406            ServerMessage::GameStarting { peer_connections } => {
407                Self::GameStarting { peer_connections }
408            }
409            ServerMessage::Pong => Self::Pong,
410            ServerMessage::Reconnected(payload) => {
411                let p = *payload;
412                Self::Reconnected {
413                    room_id: p.room_id,
414                    room_code: p.room_code,
415                    player_id: p.player_id,
416                    game_name: p.game_name,
417                    max_players: p.max_players,
418                    supports_authority: p.supports_authority,
419                    current_players: p.current_players,
420                    is_authority: p.is_authority,
421                    lobby_state: p.lobby_state,
422                    ready_players: p.ready_players,
423                    relay_type: p.relay_type,
424                    current_spectators: p.current_spectators,
425                    missed_events: p
426                        .missed_events
427                        .into_iter()
428                        .map(SignalFishEvent::from)
429                        .collect(),
430                }
431            }
432            ServerMessage::ReconnectionFailed { reason, error_code } => {
433                Self::ReconnectionFailed { reason, error_code }
434            }
435            ServerMessage::PlayerReconnected { player_id } => Self::PlayerReconnected { player_id },
436            ServerMessage::SpectatorJoined(payload) => {
437                let p = *payload;
438                Self::SpectatorJoined {
439                    room_id: p.room_id,
440                    room_code: p.room_code,
441                    spectator_id: p.spectator_id,
442                    game_name: p.game_name,
443                    current_players: p.current_players,
444                    current_spectators: p.current_spectators,
445                    lobby_state: p.lobby_state,
446                    reason: p.reason,
447                }
448            }
449            ServerMessage::SpectatorJoinFailed { reason, error_code } => {
450                Self::SpectatorJoinFailed { reason, error_code }
451            }
452            ServerMessage::SpectatorLeft {
453                room_id,
454                room_code,
455                reason,
456                current_spectators,
457            } => Self::SpectatorLeft {
458                room_id,
459                room_code,
460                reason,
461                current_spectators,
462            },
463            ServerMessage::NewSpectatorJoined {
464                spectator,
465                current_spectators,
466                reason,
467            } => Self::NewSpectatorJoined {
468                spectator,
469                current_spectators,
470                reason,
471            },
472            ServerMessage::SpectatorDisconnected {
473                spectator_id,
474                reason,
475                current_spectators,
476            } => Self::SpectatorDisconnected {
477                spectator_id,
478                reason,
479                current_spectators,
480            },
481            ServerMessage::Error {
482                message,
483                error_code,
484            } => Self::Error {
485                message,
486                error_code,
487            },
488        }
489    }
490}
491
492#[cfg(test)]
493#[allow(
494    clippy::unwrap_used,
495    clippy::expect_used,
496    clippy::panic,
497    clippy::todo,
498    clippy::unimplemented,
499    clippy::indexing_slicing
500)]
501mod tests {
502    use super::*;
503    use crate::protocol::{
504        LobbyState, ReconnectedPayload, RoomJoinedPayload, SpectatorJoinedPayload,
505    };
506
507    #[test]
508    fn connected_event_is_constructible() {
509        let event = SignalFishEvent::Connected;
510        let debug = format!("{event:?}");
511        assert!(debug.contains("Connected"));
512    }
513
514    #[test]
515    fn disconnected_event_contains_reason() {
516        let event = SignalFishEvent::Disconnected {
517            reason: Some("server shutdown".into()),
518        };
519        if let SignalFishEvent::Disconnected { reason } = event {
520            assert_eq!(reason.as_deref(), Some("server shutdown"));
521        } else {
522            panic!("expected Disconnected variant");
523        }
524    }
525
526    #[test]
527    fn from_server_message_pong() {
528        let event = SignalFishEvent::from(ServerMessage::Pong);
529        assert!(matches!(event, SignalFishEvent::Pong));
530    }
531
532    #[test]
533    fn from_server_message_room_left() {
534        let event = SignalFishEvent::from(ServerMessage::RoomLeft);
535        assert!(matches!(event, SignalFishEvent::RoomLeft));
536    }
537
538    #[test]
539    fn from_server_message_room_joined_flattens_payload() {
540        let payload = RoomJoinedPayload {
541            room_id: uuid::Uuid::nil(),
542            room_code: "ABC123".into(),
543            player_id: uuid::Uuid::nil(),
544            game_name: "test-game".into(),
545            max_players: 4,
546            supports_authority: true,
547            current_players: vec![],
548            is_authority: false,
549            lobby_state: LobbyState::Waiting,
550            ready_players: vec![],
551            relay_type: "auto".into(),
552            current_spectators: vec![],
553        };
554        let msg = ServerMessage::RoomJoined(Box::new(payload));
555        let event = SignalFishEvent::from(msg);
556        if let SignalFishEvent::RoomJoined {
557            room_code,
558            max_players,
559            game_name,
560            ..
561        } = event
562        {
563            assert_eq!(room_code, "ABC123");
564            assert_eq!(max_players, 4);
565            assert_eq!(game_name, "test-game");
566        } else {
567            panic!("expected RoomJoined variant");
568        }
569    }
570
571    #[test]
572    fn from_server_message_error() {
573        let msg = ServerMessage::Error {
574            message: "oops".into(),
575            error_code: Some(ErrorCode::InternalError),
576        };
577        let event = SignalFishEvent::from(msg);
578        if let SignalFishEvent::Error {
579            message,
580            error_code,
581        } = event
582        {
583            assert_eq!(message, "oops");
584            assert_eq!(error_code, Some(ErrorCode::InternalError));
585        } else {
586            panic!("expected Error variant");
587        }
588    }
589
590    #[test]
591    fn event_is_clone() {
592        let event = SignalFishEvent::Pong;
593        let cloned = event.clone();
594        assert!(matches!(cloned, SignalFishEvent::Pong));
595    }
596
597    #[test]
598    fn from_server_message_reconnected_flattens_payload() {
599        let payload = ReconnectedPayload {
600            room_id: uuid::Uuid::nil(),
601            room_code: "RECON1".into(),
602            player_id: uuid::Uuid::nil(),
603            game_name: "recon-game".into(),
604            max_players: 6,
605            supports_authority: false,
606            current_players: vec![],
607            is_authority: true,
608            lobby_state: LobbyState::Waiting,
609            ready_players: vec![],
610            relay_type: "tcp".into(),
611            current_spectators: vec![],
612            missed_events: vec![ServerMessage::Pong],
613        };
614        let msg = ServerMessage::Reconnected(Box::new(payload));
615        let event = SignalFishEvent::from(msg);
616        if let SignalFishEvent::Reconnected {
617            room_code,
618            max_players,
619            is_authority,
620            missed_events,
621            ..
622        } = event
623        {
624            assert_eq!(room_code, "RECON1");
625            assert_eq!(max_players, 6);
626            assert!(is_authority);
627            assert_eq!(missed_events.len(), 1);
628            assert!(matches!(missed_events[0], SignalFishEvent::Pong));
629        } else {
630            panic!("expected Reconnected variant");
631        }
632    }
633
634    #[test]
635    fn from_server_message_spectator_joined_flattens_payload() {
636        let payload = SpectatorJoinedPayload {
637            room_id: uuid::Uuid::nil(),
638            room_code: "SPEC1".into(),
639            spectator_id: uuid::Uuid::nil(),
640            game_name: "spec-game".into(),
641            current_players: vec![],
642            current_spectators: vec![],
643            lobby_state: LobbyState::Waiting,
644            reason: None,
645        };
646        let msg = ServerMessage::SpectatorJoined(Box::new(payload));
647        let event = SignalFishEvent::from(msg);
648        if let SignalFishEvent::SpectatorJoined {
649            room_code,
650            game_name,
651            reason,
652            ..
653        } = event
654        {
655            assert_eq!(room_code, "SPEC1");
656            assert_eq!(game_name, "spec-game");
657            assert!(reason.is_none());
658        } else {
659            panic!("expected SpectatorJoined variant");
660        }
661    }
662
663    #[test]
664    fn from_server_message_game_data_binary() {
665        let msg = ServerMessage::GameDataBinary {
666            from_player: uuid::Uuid::nil(),
667            encoding: GameDataEncoding::MessagePack,
668            payload: vec![0xDE, 0xAD],
669        };
670        let event = SignalFishEvent::from(msg);
671        if let SignalFishEvent::GameDataBinary {
672            from_player,
673            encoding,
674            payload,
675        } = event
676        {
677            assert_eq!(from_player, uuid::Uuid::nil());
678            assert!(matches!(encoding, GameDataEncoding::MessagePack));
679            assert_eq!(payload, vec![0xDE, 0xAD]);
680        } else {
681            panic!("expected GameDataBinary variant");
682        }
683    }
684
685    #[test]
686    fn from_server_message_authentication_error() {
687        let msg = ServerMessage::AuthenticationError {
688            error: "bad token".into(),
689            error_code: ErrorCode::InvalidAppId,
690        };
691        let event = SignalFishEvent::from(msg);
692        if let SignalFishEvent::AuthenticationError { error, error_code } = event {
693            assert_eq!(error, "bad token");
694            assert_eq!(error_code, ErrorCode::InvalidAppId);
695        } else {
696            panic!("expected AuthenticationError variant");
697        }
698    }
699}