use std::collections::{HashSet, VecDeque}; use std::io; use std::sync::{Arc, Mutex}; use std::time::Duration; use actix_web::http::header; use actix_web::{web, App as ActixApp, HttpResponse, HttpServer, Responder}; use serde::{Deserialize, Serialize}; use crate::app::Speed; use crate::data::{display_name, TEAMS}; use crate::instance::{SimStatus, SimulationInstance}; use crate::sim::SimulationType; use crate::utils::{derive_seed, Rng}; const WEB_PORT: u16 = 9009; #[derive(Clone)] struct SharedState { inner: Arc>, } struct WebState { base_seed: u64, speed: Speed, next_id: usize, instances: Vec, } impl WebState { fn new(base_seed: u64, speed: Speed) -> Self { Self { base_seed, speed, next_id: 0, instances: Vec::new(), } } fn next_seed(&self) -> u64 { derive_seed(self.base_seed, self.next_id as u64 + 1) } fn tick(&mut self) { let frames = self.speed.frames_per_tick(); for inst in &mut self.instances { if matches!(inst.status, SimStatus::Running { .. }) { inst.tick(frames); } } } fn simulation_mut(&mut self, id: usize) -> Option<&mut SimulationInstance> { self.instances.iter_mut().find(|s| s.id == id) } fn simulation(&self, id: usize) -> Option<&SimulationInstance> { self.instances.iter().find(|s| s.id == id) } fn remove_simulation(&mut self, id: usize) -> bool { let before = self.instances.len(); self.instances.retain(|s| s.id != id); self.instances.len() != before } } #[derive(Debug, Serialize)] struct ErrorDto { error: String, } #[derive(Debug, Serialize)] struct TeamDto { name: String, display_name: String, } #[derive(Debug, Serialize)] struct SimulationSummaryDto { id: usize, mode: String, status: String, seed: u64, teams: Vec, title: String, progress: String, scoreboard: String, outcome: String, } #[derive(Debug, Serialize)] struct SimulationDetailDto { id: usize, mode: String, status: String, seed: u64, teams: Vec, title: String, progress: String, scoreboard: String, outcome: String, logs: Vec, stats_lines: Vec, competition_lines: Vec, history_lines: Vec, } #[derive(Debug, Deserialize)] struct CreateSimulationRequest { mode: String, teams: Option>, auto_fill: Option, } #[derive(Debug, Serialize)] struct CreateSimulationResponse { id: usize, } fn sim_type_label(sim_type: SimulationType) -> &'static str { match sim_type { SimulationType::Single => "Single Match", SimulationType::League4 => "4-Team League", SimulationType::Knockout4 => "4-Team Knockout", } } fn mode_to_sim_type(mode: &str) -> Option { match mode { "single" => Some(SimulationType::Single), "league4" => Some(SimulationType::League4), "knockout4" => Some(SimulationType::Knockout4), _ => None, } } fn required_team_count(sim_type: SimulationType) -> usize { match sim_type { SimulationType::Single => 2, SimulationType::League4 | SimulationType::Knockout4 => 4, } } fn status_label(status: &SimStatus) -> &'static str { match status { SimStatus::Pending => "pending", SimStatus::Running { .. } => "running", SimStatus::Completed => "completed", } } fn simulation_title(sim: &SimulationInstance) -> String { match sim.sim_type { SimulationType::Single => { if sim.teams.len() == 2 { format!("Match: {} vs {}", sim.teams[0], sim.teams[1]) } else { "Match".to_string() } } _ => sim_type_label(sim.sim_type).to_string(), } } fn to_summary(sim: &SimulationInstance) -> SimulationSummaryDto { SimulationSummaryDto { id: sim.id, mode: sim.sim_type.as_str().to_string(), status: status_label(&sim.status).to_string(), seed: sim.seed, teams: sim.teams.clone(), title: simulation_title(sim), progress: sim.progress_text(), scoreboard: sim.scoreboard.clone(), outcome: sim.outcome_summary(), } } fn to_detail(sim: &SimulationInstance) -> SimulationDetailDto { SimulationDetailDto { id: sim.id, mode: sim.sim_type.as_str().to_string(), status: status_label(&sim.status).to_string(), seed: sim.seed, teams: sim.teams.clone(), title: simulation_title(sim), progress: sim.progress_text(), scoreboard: sim.scoreboard.clone(), outcome: sim.outcome_summary(), logs: vecdeque_to_vec(&sim.logs), stats_lines: sim.stats_lines.clone(), competition_lines: sim.competition_lines.clone(), history_lines: sim.history_lines.clone(), } } fn vecdeque_to_vec(items: &VecDeque) -> Vec { items.iter().cloned().collect() } fn resolve_teams( sim_type: SimulationType, provided_teams: Option>, auto_fill: bool, seed: u64, ) -> Result, String> { let required = required_team_count(sim_type); let mut selected = provided_teams.unwrap_or_default(); let mut seen = HashSet::new(); if selected.len() > required { return Err(format!( "mode {} accepts at most {} teams", sim_type.as_str(), required )); } for team in &selected { if !TEAMS.contains(&team.as_str()) { return Err(format!("Unknown team: {team}")); } if !seen.insert(team.clone()) { return Err(format!("Duplicate team: {team}")); } } if !auto_fill && selected.len() != required { return Err(format!( "mode {} requires exactly {} teams when auto_fill=false", sim_type.as_str(), required )); } if auto_fill { let mut pool: Vec<&str> = TEAMS .iter() .copied() .filter(|team| !seen.contains(*team)) .collect(); let mut rng = Rng::new(seed); while selected.len() < required { if pool.is_empty() { return Err("Not enough teams available for auto-fill".to_string()); } let i = rng.range_usize(pool.len()); selected.push(pool.remove(i).to_string()); } } Ok(selected) } async fn index_html() -> impl Responder { HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8")) .body(include_str!("../index.html")) } async fn data_js() -> impl Responder { HttpResponse::Ok() .insert_header(( header::CONTENT_TYPE, "application/javascript; charset=utf-8", )) .body(include_str!("../data.js")) } async fn api_teams() -> impl Responder { let items: Vec = TEAMS .iter() .map(|team| TeamDto { name: (*team).to_string(), display_name: display_name(team), }) .collect(); HttpResponse::Ok().json(items) } async fn api_list_simulations(state: web::Data) -> impl Responder { let guard = match state.inner.lock() { Ok(g) => g, Err(_) => { return HttpResponse::InternalServerError().json(ErrorDto { error: "state lock poisoned".to_string(), }) } }; let mut sims = guard.instances.iter().map(to_summary).collect::>(); sims.sort_by_key(|s| s.id); HttpResponse::Ok().json(sims) } async fn api_get_simulation( path: web::Path, state: web::Data, ) -> impl Responder { let id = path.into_inner(); let guard = match state.inner.lock() { Ok(g) => g, Err(_) => { return HttpResponse::InternalServerError().json(ErrorDto { error: "state lock poisoned".to_string(), }) } }; if let Some(sim) = guard.simulation(id) { return HttpResponse::Ok().json(to_detail(sim)); } HttpResponse::NotFound().json(ErrorDto { error: format!("simulation {id} not found"), }) } async fn api_create_simulation( payload: web::Json, state: web::Data, ) -> impl Responder { let mut guard = match state.inner.lock() { Ok(g) => g, Err(_) => { return HttpResponse::InternalServerError().json(ErrorDto { error: "state lock poisoned".to_string(), }) } }; let Some(sim_type) = mode_to_sim_type(payload.mode.as_str()) else { return HttpResponse::BadRequest().json(ErrorDto { error: format!("Unsupported mode: {}", payload.mode), }); }; let id = guard.next_id; let seed = guard.next_seed(); let auto_fill = payload.auto_fill.unwrap_or(true); let teams = match resolve_teams(sim_type, payload.teams.clone(), auto_fill, seed) { Ok(v) => v, Err(e) => return HttpResponse::BadRequest().json(ErrorDto { error: e }), }; let sim = SimulationInstance::new(id, sim_type, teams, seed); guard.instances.push(sim); guard.next_id += 1; HttpResponse::Created().json(CreateSimulationResponse { id }) } async fn api_start_simulation( path: web::Path, state: web::Data, ) -> impl Responder { let id = path.into_inner(); let mut guard = match state.inner.lock() { Ok(g) => g, Err(_) => { return HttpResponse::InternalServerError().json(ErrorDto { error: "state lock poisoned".to_string(), }) } }; if let Some(sim) = guard.simulation_mut(id) { sim.start(); return HttpResponse::Ok().json(to_summary(sim)); } HttpResponse::NotFound().json(ErrorDto { error: format!("simulation {id} not found"), }) } async fn api_clone_simulation( path: web::Path, state: web::Data, ) -> impl Responder { let id = path.into_inner(); let mut guard = match state.inner.lock() { Ok(g) => g, Err(_) => { return HttpResponse::InternalServerError().json(ErrorDto { error: "state lock poisoned".to_string(), }) } }; let Some(existing) = guard.simulation(id).cloned() else { return HttpResponse::NotFound().json(ErrorDto { error: format!("simulation {id} not found"), }); }; let new_id = guard.next_id; let new_seed = guard.next_seed(); let clone = existing.clone_as(new_id, new_seed); guard.instances.push(clone); guard.next_id += 1; HttpResponse::Created().json(CreateSimulationResponse { id: new_id }) } async fn api_delete_simulation( path: web::Path, state: web::Data, ) -> impl Responder { let id = path.into_inner(); let mut guard = match state.inner.lock() { Ok(g) => g, Err(_) => { return HttpResponse::InternalServerError().json(ErrorDto { error: "state lock poisoned".to_string(), }) } }; if guard.remove_simulation(id) { return HttpResponse::NoContent().finish(); } HttpResponse::NotFound().json(ErrorDto { error: format!("simulation {id} not found"), }) } async fn api_export_csv(path: web::Path, state: web::Data) -> impl Responder { let id = path.into_inner(); let guard = match state.inner.lock() { Ok(g) => g, Err(_) => { return HttpResponse::InternalServerError().json(ErrorDto { error: "state lock poisoned".to_string(), }) } }; let Some(sim) = guard.simulation(id) else { return HttpResponse::NotFound().json(ErrorDto { error: format!("simulation {id} not found"), }); }; let csv = match sim.export_csv() { Ok(bytes) => bytes, Err(e) => return HttpResponse::BadRequest().json(ErrorDto { error: e }), }; let filename = format!("sim-{}-{}.csv", sim.id, sim.sim_type.as_str()); HttpResponse::Ok() .insert_header((header::CONTENT_TYPE, "text/csv; charset=utf-8")) .insert_header(( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", filename), )) .body(csv) } pub fn run_web_server(base_seed: u64, speed: Speed) -> io::Result<()> { let shared = SharedState { inner: Arc::new(Mutex::new(WebState::new(base_seed, speed))), }; let ticker = shared.clone(); println!( "Starting SoccerCloud web UI at http://127.0.0.1:{WEB_PORT} (seed={base_seed}, speed={})", speed.label() ); actix_web::rt::System::new().block_on(async move { actix_web::rt::spawn(async move { let mut interval = actix_web::rt::time::interval(Duration::from_millis(60)); loop { interval.tick().await; if let Ok(mut guard) = ticker.inner.lock() { guard.tick(); } } }); HttpServer::new(move || { ActixApp::new() .app_data(web::Data::new(shared.clone())) .route("/", web::get().to(index_html)) .route("/index.html", web::get().to(index_html)) .route("/data.js", web::get().to(data_js)) .service( web::scope("/api") .route("/teams", web::get().to(api_teams)) .route("/simulations", web::get().to(api_list_simulations)) .route("/simulations", web::post().to(api_create_simulation)) .route("/simulations/{id}", web::get().to(api_get_simulation)) .route("/simulations/{id}", web::delete().to(api_delete_simulation)) .route( "/simulations/{id}/start", web::post().to(api_start_simulation), ) .route( "/simulations/{id}/clone", web::post().to(api_clone_simulation), ) .route( "/simulations/{id}/export.csv", web::get().to(api_export_csv), ), ) }) .bind(("127.0.0.1", WEB_PORT))? .run() .await }) }