0.3.4-alpha.3
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 87
|
# version: 88
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
@@ -14,6 +14,7 @@ members = [
|
|||||||
"crates/common/game-assets-lib",
|
"crates/common/game-assets-lib",
|
||||||
"crates/common/game-logging-lib",
|
"crates/common/game-logging-lib",
|
||||||
"crates/common/game-realtime-transport-lib",
|
"crates/common/game-realtime-transport-lib",
|
||||||
|
"crates/common/game-realtime-websocket-lib",
|
||||||
"crates/apps/game-android-entrypoint",
|
"crates/apps/game-android-entrypoint",
|
||||||
"crates/apps/game-reflex-poc-tauri",
|
"crates/apps/game-reflex-poc-tauri",
|
||||||
"crates/apps/game-reflex-poc-wasm",
|
"crates/apps/game-reflex-poc-wasm",
|
||||||
@@ -22,7 +23,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.4-alpha.2"
|
version = "0.3.4-alpha.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/games"
|
repository = "https://git.sasedev.com/Sasedev/games"
|
||||||
@@ -30,6 +31,7 @@ authors = ["Sasedev <games@sasedev.com>"]
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
futures-util = "0.3.34"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
sdl3 = "^0.20"
|
sdl3 = "^0.20"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
@@ -39,6 +41,8 @@ tracing-subscriber = "0.3.23"
|
|||||||
tauri = "2"
|
tauri = "2"
|
||||||
tauri-build = "2"
|
tauri-build = "2"
|
||||||
tauri-plugin-tracing = "^0.3"
|
tauri-plugin-tracing = "^0.3"
|
||||||
|
tokio = "1.53.1"
|
||||||
|
tokio-tungstenite = "0.30.0"
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: README.md -->
|
<!-- file: README.md -->
|
||||||
<!-- version: 55 -->
|
<!-- version: 56 -->
|
||||||
|
|
||||||
# games.sasedev
|
# games.sasedev
|
||||||
|
|
||||||
@@ -27,9 +27,9 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale
|
|||||||
|
|
||||||
Version stable de référence : `0.3.3`.
|
Version stable de référence : `0.3.3`.
|
||||||
|
|
||||||
Version candidate active : `0.3.4-alpha.2`. `0.3.2` reste différée.
|
Version candidate active : `0.3.4-alpha.3`. `0.3.2` reste différée.
|
||||||
|
|
||||||
La stable `0.3.3` livre la voie Android SDL3 native multi-ABI : build Gradle/Cargo sans orchestrateur Python, APK Debug universal et AAB Release pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, `minSdk 21` réellement fumé et compatibilité pages mémoire 16 KB validée sur les ABI 64 bits. `0.3.4-alpha.2` introduit la première API transport-neutral realtime : messages binaires opaques, fermeture distante explicite, erreurs catégorisées et split statique sender/receiver, sans dépendance Tokio ou WebSocket.
|
La stable `0.3.3` livre la voie Android SDL3 native multi-ABI : build Gradle/Cargo sans orchestrateur Python, APK Debug universal et AAB Release pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, `minSdk 21` réellement fumé et compatibilité pages mémoire 16 KB validée sur les ABI 64 bits. `0.3.4-alpha.3` conserve l'API transport-neutral de `alpha.2` et ajoute un backend WebSocket séparé fondé sur Tokio + `tokio-tungstenite`, avec client `ws://`, listener serveur, split sender/receiver, tracing et round-trip localhost déterministe.
|
||||||
|
|
||||||
Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme.
|
Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme.
|
||||||
|
|
||||||
|
|||||||
24
crates/common/game-realtime-websocket-lib/Cargo.toml
Normal file
24
crates/common/game-realtime-websocket-lib/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# file: crates/common/game-realtime-websocket-lib/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "game-realtime-websocket-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
futures-util = { workspace = true, default-features = false, features = ["sink", "std"] }
|
||||||
|
game-realtime-transport-lib = { path = "../game-realtime-transport-lib" }
|
||||||
|
tokio = { workspace = true, features = ["net"] }
|
||||||
|
tokio-tungstenite = { workspace = true, default-features = false, features = ["connect", "handshake"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
21
crates/common/game-realtime-websocket-lib/src/lib.rs
Normal file
21
crates/common/game-realtime-websocket-lib/src/lib.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/src/lib.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! Tokio/tokio-tungstenite WebSocket backend for the games.sasedev realtime transport contract.
|
||||||
|
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
|
/// Re-export of the WebSocket client connection constructor.
|
||||||
|
pub use self::websocket::connect;
|
||||||
|
/// Re-export of an established WebSocket transport connection.
|
||||||
|
pub use self::websocket::WebSocketConnection;
|
||||||
|
/// Re-export of the receive half of an established WebSocket connection.
|
||||||
|
pub use self::websocket::WebSocketReceiver;
|
||||||
|
/// Re-export of a bound WebSocket server listener.
|
||||||
|
pub use self::websocket::WebSocketListener;
|
||||||
|
/// Re-export of the send half of an established WebSocket connection.
|
||||||
|
pub use self::websocket::WebSocketSender;
|
||||||
300
crates/common/game-realtime-websocket-lib/src/websocket.rs
Normal file
300
crates/common/game-realtime-websocket-lib/src/websocket.rs
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/src/websocket.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
use futures_util::SinkExt; // rust-rules: trait-import
|
||||||
|
use futures_util::StreamExt; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const TRACING_TARGET: &str = "games::realtime::websocket";
|
||||||
|
|
||||||
|
type ClientStream = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
type ServerStream = tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>;
|
||||||
|
type ClientSink = futures_util::stream::SplitSink<ClientStream, tokio_tungstenite::tungstenite::Message>;
|
||||||
|
type ServerSink = futures_util::stream::SplitSink<ServerStream, tokio_tungstenite::tungstenite::Message>;
|
||||||
|
type ClientReceiver = futures_util::stream::SplitStream<ClientStream>;
|
||||||
|
type ServerReceiver = futures_util::stream::SplitStream<ServerStream>;
|
||||||
|
|
||||||
|
enum WebSocketStreamKind {
|
||||||
|
Client(ClientStream),
|
||||||
|
Server(ServerStream),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WebSocketSinkKind {
|
||||||
|
Client(ClientSink),
|
||||||
|
Server(ServerSink),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WebSocketReceiverKind {
|
||||||
|
Client(ClientReceiver),
|
||||||
|
Server(ServerReceiver),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Established WebSocket connection implementing the transport-neutral realtime contract.
|
||||||
|
pub struct WebSocketConnection {
|
||||||
|
inner: WebSocketStreamKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketConnection {
|
||||||
|
fn from_client(stream: ClientStream) -> Self {
|
||||||
|
return Self { inner: WebSocketStreamKind::Client(stream) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_server(stream: ServerStream) -> Self {
|
||||||
|
return Self { inner: WebSocketStreamKind::Server(stream) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl game_realtime_transport_lib::RealtimeConnection for WebSocketConnection {
|
||||||
|
type Sender = crate::WebSocketSender;
|
||||||
|
type Receiver = crate::WebSocketReceiver;
|
||||||
|
|
||||||
|
fn split(self) -> (Self::Sender, Self::Receiver) {
|
||||||
|
return match self.inner {
|
||||||
|
WebSocketStreamKind::Client(stream) => {
|
||||||
|
let (sender, receiver) = stream.split();
|
||||||
|
(
|
||||||
|
crate::WebSocketSender { inner: WebSocketSinkKind::Client(sender) },
|
||||||
|
crate::WebSocketReceiver { inner: WebSocketReceiverKind::Client(receiver) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
WebSocketStreamKind::Server(stream) => {
|
||||||
|
let (sender, receiver) = stream.split();
|
||||||
|
(
|
||||||
|
crate::WebSocketSender { inner: WebSocketSinkKind::Server(sender) },
|
||||||
|
crate::WebSocketReceiver { inner: WebSocketReceiverKind::Server(receiver) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send half of an established WebSocket transport connection.
|
||||||
|
pub struct WebSocketSender {
|
||||||
|
inner: WebSocketSinkKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl game_realtime_transport_lib::RealtimeSender for WebSocketSender {
|
||||||
|
type SendFuture<'a> = futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>> where Self: 'a;
|
||||||
|
type CloseFuture<'a> = futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>> where Self: 'a;
|
||||||
|
|
||||||
|
fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> {
|
||||||
|
return Box::pin(async move {
|
||||||
|
let payload_len = message.len();
|
||||||
|
let websocket_message = tokio_tungstenite::tungstenite::Message::Binary(message.into_bytes().into());
|
||||||
|
let result = match &mut self.inner {
|
||||||
|
WebSocketSinkKind::Client(sender) => sender.send(websocket_message).await,
|
||||||
|
WebSocketSinkKind::Server(sender) => sender.send(websocket_message).await,
|
||||||
|
};
|
||||||
|
return match result {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "binary WebSocket payload sent");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = map_stream_error(error);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket send failed");
|
||||||
|
Err(mapped)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self) -> Self::CloseFuture<'_> {
|
||||||
|
return Box::pin(async move {
|
||||||
|
let result = match &mut self.inner {
|
||||||
|
WebSocketSinkKind::Client(sender) => sender.close().await,
|
||||||
|
WebSocketSinkKind::Server(sender) => sender.close().await,
|
||||||
|
};
|
||||||
|
return match result {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::debug!(target: TRACING_TARGET, "local WebSocket close initiated");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = map_stream_error(error);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket close failed");
|
||||||
|
Err(mapped)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive half of an established WebSocket transport connection.
|
||||||
|
pub struct WebSocketReceiver {
|
||||||
|
inner: WebSocketReceiverKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl game_realtime_transport_lib::RealtimeReceiver for WebSocketReceiver {
|
||||||
|
type ReceiveFuture<'a> =
|
||||||
|
futures_util::future::LocalBoxFuture<'a, Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError>>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn receive(&mut self) -> Self::ReceiveFuture<'_> {
|
||||||
|
return Box::pin(async move {
|
||||||
|
loop {
|
||||||
|
let next_message = match &mut self.inner {
|
||||||
|
WebSocketReceiverKind::Client(receiver) => receiver.next().await,
|
||||||
|
WebSocketReceiverKind::Server(receiver) => receiver.next().await,
|
||||||
|
};
|
||||||
|
match next_message {
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(bytes))) => {
|
||||||
|
tracing::trace!(target: TRACING_TARGET, payload_len = bytes.len(), "binary WebSocket payload received");
|
||||||
|
return Ok(game_realtime_transport_lib::TransportReceive::Message(
|
||||||
|
game_realtime_transport_lib::TransportMessage::new(bytes.to_vec()),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => {
|
||||||
|
tracing::debug!(target: TRACING_TARGET, "remote WebSocket close observed");
|
||||||
|
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
||||||
|
},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(_)))
|
||||||
|
| Some(Ok(tokio_tungstenite::tungstenite::Message::Pong(_))) => {},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) => {
|
||||||
|
let error = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Protocol,
|
||||||
|
"text WebSocket messages are not part of the binary transport contract",
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unsupported WebSocket text message received");
|
||||||
|
return Err(error);
|
||||||
|
},
|
||||||
|
Some(Ok(tokio_tungstenite::tungstenite::Message::Frame(_))) => {
|
||||||
|
let error = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Protocol,
|
||||||
|
"unexpected raw WebSocket frame surfaced by the backend",
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unexpected raw WebSocket frame received");
|
||||||
|
return Err(error);
|
||||||
|
},
|
||||||
|
Some(Err(error)) => {
|
||||||
|
let mapped = map_stream_error(error);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket receive failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tracing::debug!(target: TRACING_TARGET, "WebSocket stream ended");
|
||||||
|
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bound TCP listener that upgrades accepted peers to WebSocket connections.
|
||||||
|
pub struct WebSocketListener {
|
||||||
|
listener: tokio::net::TcpListener,
|
||||||
|
local_addr: std::net::SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketListener {
|
||||||
|
/// Binds a WebSocket listener to one concrete socket address.
|
||||||
|
pub async fn bind(address: std::net::SocketAddr) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||||
|
let listener = match tokio::net::TcpListener::bind(address).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Bind,
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, address = %address, detail = mapped.detail(), "WebSocket listener bind failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let local_addr = match listener.local_addr() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Bind,
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebSocket listener address lookup failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebSocket listener bound");
|
||||||
|
return Ok(Self { listener, local_addr });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the concrete local socket address, including an ephemeral port selected by the OS.
|
||||||
|
#[must_use]
|
||||||
|
pub fn local_addr(&self) -> std::net::SocketAddr {
|
||||||
|
return self.local_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts one TCP peer and completes the server-side WebSocket handshake.
|
||||||
|
pub async fn accept(&self) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
|
||||||
|
let (stream, peer_addr) = match self.listener.accept().await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Accept,
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebSocket TCP accept failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let websocket = match tokio_tungstenite::accept_async(stream).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Accept,
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
tracing::warn!(target: TRACING_TARGET, peer = %peer_addr, detail = mapped.detail(), "WebSocket server handshake failed");
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, peer = %peer_addr, "WebSocket peer accepted");
|
||||||
|
return Ok(crate::WebSocketConnection::from_server(websocket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a client to one plain `ws://` endpoint and completes the WebSocket handshake.
|
||||||
|
pub async fn connect(endpoint: &str) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
|
||||||
|
if !endpoint.starts_with("ws://") {
|
||||||
|
return Err(game_realtime_transport_lib::TransportError::new(
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration,
|
||||||
|
"the baseline WebSocket backend accepts only ws:// endpoints",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::debug!(target: TRACING_TARGET, endpoint = endpoint, "connecting WebSocket client");
|
||||||
|
let (stream, _) = match tokio_tungstenite::connect_async(endpoint).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let mapped = map_connect_error(error);
|
||||||
|
tracing::warn!(
|
||||||
|
target: TRACING_TARGET,
|
||||||
|
endpoint = endpoint,
|
||||||
|
kind = %mapped.kind(),
|
||||||
|
detail = mapped.detail(),
|
||||||
|
"WebSocket client connect failed"
|
||||||
|
);
|
||||||
|
return Err(mapped);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(target: TRACING_TARGET, endpoint = endpoint, "WebSocket client connected");
|
||||||
|
return Ok(crate::WebSocketConnection::from_client(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_connect_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError {
|
||||||
|
let kind = match &error {
|
||||||
|
tokio_tungstenite::tungstenite::Error::Url(_) => game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration,
|
||||||
|
_ => game_realtime_transport_lib::TransportErrorKind::Connect,
|
||||||
|
};
|
||||||
|
return game_realtime_transport_lib::TransportError::new(kind, error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_stream_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError {
|
||||||
|
let kind = match &error {
|
||||||
|
tokio_tungstenite::tungstenite::Error::ConnectionClosed | tokio_tungstenite::tungstenite::Error::AlreadyClosed => {
|
||||||
|
game_realtime_transport_lib::TransportErrorKind::Closed
|
||||||
|
},
|
||||||
|
tokio_tungstenite::tungstenite::Error::Io(_) => game_realtime_transport_lib::TransportErrorKind::Io,
|
||||||
|
tokio_tungstenite::tungstenite::Error::Capacity(_) => game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
|
||||||
|
tokio_tungstenite::tungstenite::Error::WriteBufferFull(_) => game_realtime_transport_lib::TransportErrorKind::Backpressure,
|
||||||
|
_ => game_realtime_transport_lib::TransportErrorKind::Protocol,
|
||||||
|
};
|
||||||
|
return game_realtime_transport_lib::TransportError::new(kind, error.to_string());
|
||||||
|
}
|
||||||
63
crates/common/game-realtime-websocket-lib/tests/loopback.rs
Normal file
63
crates/common/game-realtime-websocket-lib/tests/loopback.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// file: crates/common/game-realtime-websocket-lib/tests/loopback.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Deterministic localhost proof for the Tokio/tokio-tungstenite backend.
|
||||||
|
|
||||||
|
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||||
|
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn binary_round_trip_and_clean_close_work_on_loopback() {
|
||||||
|
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => panic!("loopback listener bind failed: {error}"),
|
||||||
|
};
|
||||||
|
let endpoint = format!("ws://{}/", listener.local_addr());
|
||||||
|
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||||
|
return tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint.as_str()));
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let (server_connection, client_connection) = match pair {
|
||||||
|
Ok((Ok(server), Ok(client))) => (server, client),
|
||||||
|
Ok((_server, _client)) => panic!("loopback connection establishment failed"),
|
||||||
|
Err(_) => panic!("loopback connection establishment timed out"),
|
||||||
|
};
|
||||||
|
let (mut server_sender, mut server_receiver) = server_connection.split();
|
||||||
|
let (mut client_sender, mut client_receiver) = client_connection.split();
|
||||||
|
let client_payload = game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255]);
|
||||||
|
let client_send = tokio::time::timeout(TEST_TIMEOUT, client_sender.send(client_payload)).await;
|
||||||
|
assert!(matches!(client_send, Ok(Ok(()))));
|
||||||
|
let server_receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
|
||||||
|
match server_receive {
|
||||||
|
Ok(Ok(received)) => assert_eq!(
|
||||||
|
received,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255]))
|
||||||
|
),
|
||||||
|
Ok(Err(error)) => panic!("server receive failed: {error}"),
|
||||||
|
Err(_) => panic!("server receive timed out"),
|
||||||
|
}
|
||||||
|
let server_payload = game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6]);
|
||||||
|
let server_send = tokio::time::timeout(TEST_TIMEOUT, server_sender.send(server_payload)).await;
|
||||||
|
assert!(matches!(server_send, Ok(Ok(()))));
|
||||||
|
let client_receive = tokio::time::timeout(TEST_TIMEOUT, client_receiver.receive()).await;
|
||||||
|
match client_receive {
|
||||||
|
Ok(Ok(received)) => assert_eq!(
|
||||||
|
received,
|
||||||
|
game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6]))
|
||||||
|
),
|
||||||
|
Ok(Err(error)) => panic!("client receive failed: {error}"),
|
||||||
|
Err(_) => panic!("client receive timed out"),
|
||||||
|
}
|
||||||
|
let client_close = tokio::time::timeout(TEST_TIMEOUT, client_sender.close()).await;
|
||||||
|
assert!(matches!(client_close, Ok(Ok(()))));
|
||||||
|
let server_close_observation = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
|
||||||
|
match server_close_observation {
|
||||||
|
Ok(Ok(received)) => assert_eq!(received, game_realtime_transport_lib::TransportReceive::Closed),
|
||||||
|
Ok(Err(error)) => panic!("server close observation failed: {error}"),
|
||||||
|
Err(_) => panic!("server close observation timed out"),
|
||||||
|
};
|
||||||
|
}
|
||||||
144
deltas/0.3.4/alpha.3.md
Normal file
144
deltas/0.3.4/alpha.3.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<!-- file: deltas/0.3.4/alpha.3.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta 0.3.4-alpha.3
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Introduire le premier backend concret de la frontière realtime validée en `alpha.2` : WebSocket async natif avec Tokio + `tokio-tungstenite`, sans TLS, sans protocole session/gameplay et sans dépendance backend dans les moteurs ou jeux.
|
||||||
|
|
||||||
|
## Historique fermé
|
||||||
|
|
||||||
|
`history/0.3.4/alpha.2.md` enregistre la validation utilisateur : audits propres, workspace/Clippy strict propres et `7` tests sur `game-realtime-transport-lib` passés.
|
||||||
|
|
||||||
|
Aucun `alpha.2.fix.N` n'est requis.
|
||||||
|
|
||||||
|
## Version et dépendances
|
||||||
|
|
||||||
|
Le workspace passe à :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.3.4-alpha.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Dépendances tierces centralisées au workspace :
|
||||||
|
|
||||||
|
```text
|
||||||
|
futures-util 0.3.34
|
||||||
|
tokio 1.53.1
|
||||||
|
tokio-tungstenite 0.30.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`game-realtime-websocket-lib` active uniquement les features dont elle a besoin :
|
||||||
|
|
||||||
|
```text
|
||||||
|
futures-util: default-features=false, sink, std
|
||||||
|
tokio: net
|
||||||
|
tokio-tungstenite: default-features=false, connect, handshake
|
||||||
|
```
|
||||||
|
|
||||||
|
Le harness de test ajoute localement les features Tokio `macros`, `rt` et `time`. Aucune feature TLS n'est activée.
|
||||||
|
|
||||||
|
## Nouvelle crate WebSocket
|
||||||
|
|
||||||
|
Ajout :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/common/game-realtime-websocket-lib/Cargo.toml
|
||||||
|
crates/common/game-realtime-websocket-lib/src/lib.rs
|
||||||
|
crates/common/game-realtime-websocket-lib/src/websocket.rs
|
||||||
|
crates/common/game-realtime-websocket-lib/tests/loopback.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
La crate implémente le contrat `game-realtime-transport-lib` sans en modifier l'API.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
`connect(&str)` établit une connexion WebSocket cliente à partir d'un endpoint `ws://`. Un endpoint hors baseline, notamment `wss://`, est rejeté comme `InvalidConfiguration` au lieu d'activer implicitement un backend TLS.
|
||||||
|
|
||||||
|
### Serveur
|
||||||
|
|
||||||
|
`WebSocketListener::bind(SocketAddr)` bind un `tokio::net::TcpListener`; `127.0.0.1:0` permet au système de choisir un port de test éphémère. `accept()` accepte le TCP puis effectue le handshake WebSocket serveur.
|
||||||
|
|
||||||
|
### Connexion
|
||||||
|
|
||||||
|
Client et serveur retournent le même `WebSocketConnection` public. Les représentations `tokio_tungstenite::WebSocketStream<...>` restent privées.
|
||||||
|
|
||||||
|
`RealtimeConnection::split()` produit `WebSocketSender` et `WebSocketReceiver`. Les futures concrètes sont boxées uniquement dans ce backend afin d'implémenter les associated futures GAT du contrat. Les représentations WebSocket/Tungstenite restent privées ; l'alias public `LocalBoxFuture` de `futures-util` sert uniquement de représentation concrète des associated futures du backend.
|
||||||
|
|
||||||
|
### Mapping WebSocket
|
||||||
|
|
||||||
|
```text
|
||||||
|
Binary -> TransportReceive::Message
|
||||||
|
Close -> TransportReceive::Closed
|
||||||
|
Ping / Pong -> détail de contrôle backend, non remonté au gameplay
|
||||||
|
Text -> TransportErrorKind::Protocol
|
||||||
|
Frame -> TransportErrorKind::Protocol
|
||||||
|
WriteBufferFull -> TransportErrorKind::Backpressure
|
||||||
|
Capacity -> TransportErrorKind::MessageTooLarge
|
||||||
|
I/O -> TransportErrorKind::Io
|
||||||
|
closed/already -> TransportErrorKind::Closed
|
||||||
|
```
|
||||||
|
|
||||||
|
Les erreurs de connect/bind/accept restent catégorisées au niveau de l'opération qui échoue et aucun `tungstenite::Error` ne fuit dans l'API publique.
|
||||||
|
|
||||||
|
## Tracing
|
||||||
|
|
||||||
|
Le target backend est :
|
||||||
|
|
||||||
|
```text
|
||||||
|
games::realtime::websocket
|
||||||
|
```
|
||||||
|
|
||||||
|
Les événements couvrent connect, bind, accept, send, receive, close et erreurs. Le contenu brut des payloads n'est jamais loggué ; seule leur longueur peut apparaître au niveau trace.
|
||||||
|
|
||||||
|
La crate ne configure aucun subscriber global.
|
||||||
|
|
||||||
|
## Preuve loopback
|
||||||
|
|
||||||
|
Le test d'intégration public :
|
||||||
|
|
||||||
|
```text
|
||||||
|
binary_round_trip_and_clean_close_work_on_loopback
|
||||||
|
```
|
||||||
|
|
||||||
|
utilise `127.0.0.1:0`, établit client et serveur sans Internet ni port fixe, puis vérifie :
|
||||||
|
|
||||||
|
- payload binaire client vers serveur ;
|
||||||
|
- payload binaire serveur vers client ;
|
||||||
|
- fermeture locale cliente observée comme fermeture distante propre côté serveur.
|
||||||
|
|
||||||
|
Une borne de trois secondes entoure les étapes asynchrones uniquement pour rendre le test déterministe en cas de régression ; elle ne constitue pas encore le timeout de transport produit, réservé à `alpha.4`.
|
||||||
|
|
||||||
|
## Documentation locale
|
||||||
|
|
||||||
|
Aucun `README.md` ou `USAGE.md` local n'est ajouté pour cette nouvelle crate pendant `alpha.3`. Le contrat reste petit, la rustdoc décrit l'API et le plan central porte les décisions d'architecture. Cette décision sera réévaluée pendant la consolidation finale conformément à `DOC-CRATE-*`.
|
||||||
|
|
||||||
|
## Fichiers existants modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
README.md
|
||||||
|
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROADMAP.md` et `CHANGELOG.md` restent inchangés : le scope macro de `0.3.4` n'a pas changé et une alpha n'ajoute pas encore de changelog de release.
|
||||||
|
|
||||||
|
## Validation attendue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
|
||||||
|
python3 scripts/audit_distribution_layout.py
|
||||||
|
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
cargo test -p game-realtime-transport-lib --all-targets --all-features
|
||||||
|
cargo test -p game-realtime-websocket-lib --all-targets --all-features
|
||||||
|
cargo tree -p game-realtime-websocket-lib --edges normal
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo test --workspace --all-targets --all-features` reste réservé à la beta conformément au plan.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md -->
|
<!-- file: docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 3 -->
|
||||||
|
|
||||||
# Plan 0.3.4 — transport realtime et baseline WebSocket
|
# Plan 0.3.4 — transport realtime et baseline WebSocket
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
Plan actif créé pendant `0.3.4-alpha.1` à partir de l'archive taggée `v0.3.3`.
|
Plan actif créé pendant `0.3.4-alpha.1` à partir de l'archive taggée `v0.3.3`.
|
||||||
|
|
||||||
La tranche `alpha.1` a été validée le 2026-09-21. La tranche active `alpha.2` matérialise maintenant le contrat transport-neutral sans dépendance runtime/backend ; le backend WebSocket reste réservé à `alpha.3`.
|
Les tranches `alpha.1` et `alpha.2` ont été validées le 2026-09-21. La tranche active `alpha.3` ajoute maintenant le backend WebSocket Tokio/tokio-tungstenite derrière le contrat transport-neutral validé ; les limites, timeouts et cas négatifs complets restent réservés à `alpha.4`.
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Les anciens labels `0-pre`, `1-alpha`, `2-beta` et `3-rc` restent valides unique
|
|||||||
|
|
||||||
## Dépendances vérifiées au 2026-09-21
|
## Dépendances vérifiées au 2026-09-21
|
||||||
|
|
||||||
Versions amont observées pour la future implémentation :
|
Versions amont retenues et introduites par `alpha.3` :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tokio 1.53.1
|
Tokio 1.53.1
|
||||||
@@ -82,7 +82,7 @@ Contraintes utiles :
|
|||||||
- `native-tls` et les variantes `rustls-*` sont optionnelles ;
|
- `native-tls` et les variantes `rustls-*` sont optionnelles ;
|
||||||
- Tungstenite expose déjà des limites de message/frame et de write buffer configurables.
|
- Tungstenite expose déjà des limites de message/frame et de write buffer configurables.
|
||||||
|
|
||||||
La compatibilité effective de la toolchain utilisateur avec ces MSRV sera attestée par les gates Cargo de la tranche qui introduira réellement les dépendances. Elles ne sont pas ajoutées pendant `alpha.1` : elles seront introduites uniquement avec les crates qui les consomment réellement.
|
`alpha.3` centralise désormais ces versions sous `[workspace.dependencies]` et active les features uniquement dans `game-realtime-websocket-lib`. `futures-util` est consommé avec `default-features = false` et seulement `sink + std`; `tokio-tungstenite` est consommé avec `default-features = false` et seulement `connect + handshake`; aucune feature TLS n'est activée. La compatibilité effective de la toolchain utilisateur avec ces MSRV sera attestée par la gate Cargo utilisateur de cette tranche.
|
||||||
|
|
||||||
## Ownership physique retenu
|
## Ownership physique retenu
|
||||||
|
|
||||||
@@ -284,6 +284,29 @@ Ne pas logguer par défaut le contenu brut des payloads de transport.
|
|||||||
|
|
||||||
Les futurs domaines session/sync/simulation utilisent des targets distinctes.
|
Les futurs domaines session/sync/simulation utilisent des targets distinctes.
|
||||||
|
|
||||||
|
## Décisions matérialisées dans `alpha.3`
|
||||||
|
|
||||||
|
Le backend concret reste sous `crates/common/game-realtime-websocket-lib` et dépend uniquement du contrat commun plus des briques réseau/tracing nécessaires.
|
||||||
|
|
||||||
|
L'API publique expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
connect(endpoint)
|
||||||
|
WebSocketListener::bind(address)
|
||||||
|
WebSocketListener::accept()
|
||||||
|
WebSocketConnection
|
||||||
|
WebSocketSender
|
||||||
|
WebSocketReceiver
|
||||||
|
```
|
||||||
|
|
||||||
|
Les types `WebSocketStream`, `SplitSink`, `SplitStream`, `MaybeTlsStream` et `tungstenite::Error` restent privés. Client et serveur produisent le même `WebSocketConnection` public grâce à une représentation interne à deux variantes ; le contrat commun reste donc la seule frontière partagée par les consommateurs.
|
||||||
|
|
||||||
|
`connect()` accepte volontairement uniquement `ws://` dans cette baseline. Le TLS direct reste différé. Les frames binaires deviennent `TransportMessage`; Close devient `TransportReceive::Closed`; Ping/Pong sont traités comme contrôle backend ; Text et raw Frame sont explicitement rejetés comme erreurs de protocole. Les tests négatifs exhaustifs correspondants restent planifiés pour `alpha.4`.
|
||||||
|
|
||||||
|
Le backend ne crée aucun runtime, thread ni task détachée. Le harness d'intégration utilise un runtime Tokio de test courant, un bind `127.0.0.1:0` et une borne temporelle uniquement pour empêcher un test loopback défectueux de rester suspendu.
|
||||||
|
|
||||||
|
Aucun `README.md`/`USAGE.md` local n'est ajouté pendant `alpha.3` : la crate reste petite, son API publique est documentée par rustdoc et le présent plan porte encore les décisions durables. Ce choix sera réévalué pendant la consolidation finale conformément à `DOC-CRATE-*`.
|
||||||
|
|
||||||
## Tests retenus
|
## Tests retenus
|
||||||
|
|
||||||
### Contrat commun
|
### Contrat commun
|
||||||
|
|||||||
48
history/0.3.4/alpha.2.md
Normal file
48
history/0.3.4/alpha.2.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!-- file: history/0.3.4/alpha.2.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Historique 0.3.4-alpha.2
|
||||||
|
|
||||||
|
## Statut
|
||||||
|
|
||||||
|
`0.3.4-alpha.2` a été validée par l'utilisateur le 2026-09-21. Le contrat transport-neutral compile dans le workspace, passe Clippy strict et ses sept tests ciblés sont propres.
|
||||||
|
|
||||||
|
Aucun `alpha.2.fix.N` n'est requis. La suite peut ouvrir `0.3.4-alpha.3` et introduire le backend WebSocket Tokio/tokio-tungstenite planifié.
|
||||||
|
|
||||||
|
## Gates statiques et Rust
|
||||||
|
|
||||||
|
La gate fournie est propre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
General Rust rule audit: clean
|
||||||
|
Rust export completeness audit: 0 candidate(s)
|
||||||
|
games.sasedev workspace audit: clean
|
||||||
|
Markdown table audit: clean (5 table(s), 262 file(s))
|
||||||
|
Distribution layout audit: clean (49 required path(s), 8 forbidden path(s) absent)
|
||||||
|
cargo fmt --all: clean
|
||||||
|
cargo fmt --all -- --check: clean
|
||||||
|
cargo check --workspace: clean
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Le workspace compilé porte `0.3.4-alpha.2`, y compris `game-realtime-transport-lib`.
|
||||||
|
|
||||||
|
## Tests du contrat transport
|
||||||
|
|
||||||
|
La commande :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo test -p game-realtime-transport-lib --all-targets --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
termine avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests couvrent le payload binaire possédé, le payload vide, la distinction message/fermeture propre, le split sender/receiver, les futures associées de send/receive/close et les catégories d'erreur transport-neutral.
|
||||||
|
|
||||||
|
## Conséquence
|
||||||
|
|
||||||
|
La frontière commune est acceptée sans Tokio, Tungstenite ni runtime concret. `alpha.3` peut ajouter une crate backend séparée qui implémente ce contrat, tout en conservant le gameplay et les moteurs indépendants du backend WebSocket.
|
||||||
Reference in New Issue
Block a user