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}