//! Shared fixtures for the tavolo-platform test suite. //! //! `DummyEngine` is a tiny two-player game implementing the platform's //! `GameEngine` contract, so every platform behaviour (lobby, store, //! websockets, deadlines, stats) is exercised without importing any real //! game. Its rules: the first player to reach `target` plays wins. #![allow(dead_code)] use std::sync::Arc; use axum::body::Body; use axum::http::{Request, Response, StatusCode}; use axum::Router; use futures_util::StreamExt; use serde_json::{json, Map, Value}; use tower::ServiceExt; use tavolo_platform::deadlines::DeadlineScheduler; use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat}; use tavolo_platform::errors::{Error, GameError, Result}; use tavolo_platform::oidc::{OidcProviderConfig, OidcProviders}; use tavolo_platform::platform::{Platform, SharedPlatform}; use tavolo_platform::registry::GameRegistry; use tavolo_platform::session::{session_middleware, InMemorySessionStore, SharedSessionStore}; use tavolo_platform::stats::{InMemoryStatsRepo, SharedStatsRepo}; use tavolo_platform::store::{InMemoryGameStore, SharedGameStore}; #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DummyState { pub target: i64, pub plays: Vec, pub started: bool, pub finished: bool, pub winner: Option, pub deadline_in_seconds: Option, } pub struct DummyEngine; fn state_of(session: &GameSession) -> Result<&DummyState> { session .state .as_ref() .and_then(|state| state.downcast_ref::()) .ok_or_else(|| Error::Internal("dummy state missing".into())) } fn state_of_mut(session: &mut GameSession) -> Result<&mut DummyState> { session .state .as_mut() .and_then(|state| state.downcast_mut::()) .ok_or_else(|| Error::Internal("dummy state missing".into())) } impl DummyEngine { fn play(session: &mut GameSession, user_sub: &str) -> Result<()> { let state = state_of_mut(session)?; state.plays.push(user_sub.to_string()); if state.plays.len() as i64 >= state.target { state.finished = true; state.winner = Some(user_sub.to_string()); } Ok(()) } } impl GameEngine for DummyEngine { fn id(&self) -> &'static str { "dummy" } fn name(&self) -> &'static str { "Dummy game" } fn description(&self) -> &'static str { "A two-player toy game for testing the platform." } fn min_players(&self) -> usize { 2 } fn max_players(&self) -> usize { 2 } fn options_schema(&self) -> Value { json!({ "type": "object", "properties": { "target": { "type": "integer", "minimum": 1, "default": 3, "description": "Plays needed to win the match.", }, "deadline_in_seconds": { "type": "number", "description": "Arm a tick deadline this far in the future.", }, }, }) } fn create(&self, session: &mut GameSession, options: &Map) -> Result<()> { let target = options.get("target").and_then(Value::as_i64).unwrap_or(3); if target < 1 || options.get("target").is_some_and(|v| v.is_boolean()) { return Err(GameError::illegal_move("target must be a positive integer").into()); } let creator = session.players[0].clone(); session.players[0] = Seat { user_sub: creator.user_sub, display_name: creator.display_name, team: Some("A".into()), }; session.state = Some(Box::new(DummyState { target, plays: Vec::new(), started: false, finished: false, winner: None, deadline_in_seconds: options.get("deadline_in_seconds").and_then(Value::as_f64), })); Ok(()) } fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> { if state_of(session)?.started { return Err(GameError::game_not_started("game has already started").into()); } if session.seated(user_sub) { return Err(GameError::already_joined("already joined this game").into()); } if session.players.len() >= 2 { return Err(GameError::lobby_full("game is full").into()); } let team = if session.players.is_empty() { "A" } else { "B" }; session.players.push(Seat { user_sub: user_sub.into(), display_name: display_name.into(), team: Some(team.into()), }); if session.players.len() == 2 { state_of_mut(session)?.started = true; } Ok(()) } fn handle_action( &self, session: &mut GameSession, user_sub: &str, action: &str, _payload: &Map, ) -> Result<()> { if !state_of(session)?.started { return Err(GameError::game_not_started("the game has not started yet").into()); } if state_of(session)?.finished { return Err(GameError::game_finished("the match is over").into()); } if !session.seated(user_sub) { return Err(GameError::not_your_turn("you are not seated in this game").into()); } if action != "play" { return Err(GameError::illegal_move(format!( "unknown action: {}", tavolo_platform::http::py_repr(&Value::String(action.to_string())) )) .into()); } DummyEngine::play(session, user_sub) } fn view_for(&self, session: &GameSession, user_sub: &str) -> Value { let state = state_of(session).expect("dummy state"); json!({ "started": state.started, "finished": state.finished, "winner": state.winner, "plays": state.plays.len(), "target": state.target, "viewer_seated": session.seated(user_sub), }) } fn lobby_view(&self, session: &GameSession) -> Value { let state = state_of(session).expect("dummy state"); json!({ "phase": if state.started { "playing" } else { "lobby" }, "target": state.target, }) } fn in_lobby(&self, session: &GameSession) -> bool { state_of(session) .map(|state| !state.started) .unwrap_or(false) } fn is_finished(&self, session: &GameSession) -> bool { state_of(session) .map(|state| state.finished) .unwrap_or(false) } fn result(&self, session: &GameSession) -> Result { let state = state_of(session)?; let Some(winner) = state.winner.clone() else { return Err(GameError::illegal_move("no result: the match is not finished").into()); }; let subs: Vec = session .players .iter() .map(|seat| seat.user_sub.clone()) .collect(); Ok(MatchResult { teams: vec![vec![subs[0].clone()], vec![subs[1].clone()]], winner_team: subs.iter().position(|sub| *sub == winner).unwrap_or(0), players: session .players .iter() .enumerate() .map(|(index, seat)| PlayerResult { user_sub: seat.user_sub.clone(), seat: index as i64, won: seat.user_sub == winner, team: seat.team.clone(), score: state .plays .iter() .filter(|sub| **sub == seat.user_sub) .count() as f64, details: json!({ "plays": state.plays.iter().filter(|sub| **sub == seat.user_sub).count(), }), }) .collect(), summary: json!({ "target": state.target, "plays": state.plays.len(), "winner": winner, }), }) } fn state_to_json(&self, state: &(dyn std::any::Any + Send + Sync)) -> Result { let state = state .downcast_ref::() .ok_or_else(|| Error::Internal("dummy state missing".into()))?; Ok(serde_json::to_value(state)?) } fn state_from_json(&self, data: &Value) -> Result> { Ok(Box::new(serde_json::from_value::( data.clone(), )?)) } fn next_deadline(&self, session: &GameSession) -> Result> { let state = state_of(session)?; let Some(seconds) = state.deadline_in_seconds else { return Ok(None); }; if !state.started || state.finished { return Ok(None); } let due_at = tavolo_platform::datetime::now() + chrono::Duration::milliseconds((seconds * 1000.0) as i64); Ok(Some(Deadline { kind: "tick".into(), due_at, token: format!("tick:{}", state.plays.len()), })) } fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> { let current = self.next_deadline(session)?; match current { Some(current) if current.kind == kind && current.token == token => {} _ => return Err(GameError::illegal_move("stale deadline").into()), } let user_sub = session.players[0].user_sub.clone(); DummyEngine::play(session, &user_sub) } } fn test_oidc_config(provider: &str) -> OidcProviderConfig { OidcProviderConfig { provider: provider.into(), issuer: format!("http://localhost:8180/{provider}"), client_id: "tavolo".into(), client_secret: None, redirect_uri: format!("http://localhost:8080/auth/{provider}/callback"), post_login_redirect: "/".into(), post_logout_redirect: "/".into(), } } pub struct TestPlatform { pub platform: SharedPlatform, pub session_store: SharedSessionStore, pub stats: Arc, } impl TestPlatform { pub fn router(&self) -> Router { Router::new() .merge( tavolo_platform::routes::router(&["default".to_string()]) .with_state(self.platform.clone()), ) .layer(axum::middleware::from_fn_with_state( self.session_store.clone(), session_middleware, )) } } pub fn make_platform(heartbeat_ms: u64) -> TestPlatform { let mut registry = GameRegistry::new(); registry .register(Arc::new(DummyEngine)) .expect("register dummy"); let registry = Arc::new(registry); let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone())); let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new()); let stats = Arc::new(InMemoryStatsRepo::new()); let stats_repo: SharedStatsRepo = stats.clone(); let oidc = Arc::new(OidcProviders::new(vec![test_oidc_config("default")]).expect("oidc providers")); let scheduler = DeadlineScheduler::new( game_store.clone(), registry.clone(), stats_repo.clone(), heartbeat_ms, 20, ); scheduler.start(); let platform = Arc::new(Platform { registry, game_store, scheduler, oidc, stats: stats_repo, }); TestPlatform { platform, session_store, stats, } } /// Persist a logged-in session and return the `Cookie` header value for it. pub async fn login_cookie( store: &SharedSessionStore, provider: &str, sub: &str, name: &str, ) -> String { let id = format!("session-{sub}"); let mut data = Map::new(); data.insert( format!("oidc:{provider}:user"), json!({"sub": sub, "preferred_username": name}), ); store .save(&id, &data, Some(3600)) .await .expect("save session"); format!("session_id={id}") } pub async fn call(router: &Router, request: Request) -> Response { router.clone().oneshot(request).await.expect("request") } pub async fn body_json(response: Response) -> Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); serde_json::from_slice(&bytes).expect("json body") } pub fn get(uri: &str, cookie: Option<&str>) -> Request { let mut builder = Request::builder().method("GET").uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } builder.body(Body::empty()).expect("request") } pub fn post_json(uri: &str, cookie: Option<&str>, body: &Value) -> Request { let mut builder = Request::builder() .method("POST") .uri(uri) .header("content-type", "application/json"); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } builder .body(Body::from(serde_json::to_vec(body).expect("json"))) .expect("request") } pub async fn read_text( mut socket: tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, ) -> String { let message = socket .next() .await .expect("some message") .expect("ok message"); match message { tokio_tungstenite::tungstenite::Message::Text(text) => text.to_string(), other => panic!("expected text message, got {other:?}"), } } pub async fn read_json( socket: &mut tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, ) -> Value { let message = socket .next() .await .expect("some message") .expect("ok message"); match message { tokio_tungstenite::tungstenite::Message::Text(text) => { serde_json::from_str(&text).expect("json message") } other => panic!("expected text message, got {other:?}"), } } pub fn ws_request(addr: std::net::SocketAddr, game_id: &str, cookie: Option<&str>) -> Request<()> { use tokio_tungstenite::tungstenite::client::IntoClientRequest; let url = format!("ws://{addr}/ws/games/{game_id}"); let mut request = url.into_client_request().expect("websocket client request"); if let Some(cookie) = cookie { request.headers_mut().insert( "cookie", axum::http::HeaderValue::from_str(cookie).expect("cookie header"), ); } request } pub async fn start_server(router: Router) -> std::net::SocketAddr { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let addr = listener.local_addr().expect("addr"); tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); addr } pub fn json_status(response: &Response) -> StatusCode { response.status() }