From 513d1162ed4000d54b75f8f3689c79fac12a03e3 Mon Sep 17 00:00:00 2001 From: markmental Date: Fri, 13 Feb 2026 14:44:39 -0500 Subject: [PATCH] Added demo mode --- Cargo.lock | 2 +- Cargo.toml | 2 +- DEVLOG.md | 12 ++++++++++++ data.js | 21 ++++++++++++++++++--- index.html | 16 ++++++++++++++++ src/main.rs | 12 +++++++++++- src/web.rs | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 103 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef61a65..7859760 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1424,7 +1424,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "soccercloud" -version = "0.1.0" +version = "0.1.1" dependencies = [ "actix-web", "clap", diff --git a/Cargo.toml b/Cargo.toml index 881ccd2..c9dd4d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "soccercloud" -version = "0.1.0" +version = "0.1.1" edition = "2021" [dependencies] diff --git a/DEVLOG.md b/DEVLOG.md index bbb0f86..4a2c389 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,5 +1,17 @@ # DEVLOG +## 2026-02-13 - v0.1.1 Demo mode + +### Scope completed +- Added `--demo` CLI argument for web mode only. +- Demo mode features: + - Limited to 6 simulations maximum + - FIFO rotation: creating a 7th simulation removes the oldest + - Manual deletion disabled (returns 403 Forbidden) + - Demo banner displayed in hero-card element +- Added `/api/config` endpoint to expose demo mode status to frontend. +- Frontend hides delete buttons when in demo mode. + ## 2026-02-12 - v0.1.0 Initial Public Release ### Scope completed diff --git a/data.js b/data.js index e83f1d4..e4af352 100644 --- a/data.js +++ b/data.js @@ -3,6 +3,7 @@ const state = { simulations: [], selectedDetailId: null, pollHandle: null, + demo: false, }; const $ = (id) => document.getElementById(id); @@ -148,10 +149,11 @@ function getCreatePayload() { } function cardActions(sim) { + const deleteButton = state.demo ? "" : ``; const common = ` - + ${deleteButton} `; if (sim.status === "pending") { @@ -418,11 +420,24 @@ async function boot() { initThemeControls(); bindEvents(); try { - setStatus("Loading teams and simulations..."); + setStatus("Loading configuration, teams and simulations..."); + const config = await request("/api/config"); + state.demo = config.demo; + + // Show/hide demo banner + const demoBanner = $("demoBanner"); + if (demoBanner) { + if (state.demo) { + demoBanner.classList.add("visible"); + } else { + demoBanner.classList.remove("visible"); + } + } + state.teams = await request("/api/teams"); renderTeamSelectors(); await refreshSimulations(); - setStatus("Connected to SoccerCloud backend on port 9009."); + setStatus(`Connected to SoccerCloud backend on port 9009.${state.demo ? " (Demo mode)" : ""}`); } catch (error) { setStatus(`Startup failed: ${error.message}`); return; diff --git a/index.html b/index.html index 0add5b9..4bffefe 100644 --- a/index.html +++ b/index.html @@ -221,6 +221,21 @@ animation: rise 420ms ease both; } + .demo-banner { + background: linear-gradient(135deg, #b42318, #dc2626); + color: white; + padding: 10px 16px; + border-radius: 10px; + margin-bottom: 14px; + font-size: 13px; + font-weight: 600; + display: none; + } + + .demo-banner.visible { + display: block; + } + .eyebrow { font-size: 12px; text-transform: uppercase; @@ -609,6 +624,7 @@
+
DEMO MODE - Limited to 6 simulations with auto-rotation. Manual deletion disabled.

Rust Backend

SoccerCloud Web Control Room

diff --git a/src/main.rs b/src/main.rs index 2ecef92..b8962b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,9 @@ struct Cli { #[arg(long, global = true)] listen_open: bool, + #[arg(long, global = true)] + demo: bool, + #[command(subcommand)] command: Option, } @@ -93,7 +96,14 @@ fn main() -> io::Result<()> { "--web cannot be combined with subcommands", )); } - return run_web_server(base_seed, cli.speed, cli.listen_open); + return run_web_server(base_seed, cli.speed, cli.listen_open, cli.demo); + } + + if cli.demo { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "--demo can only be used with --web", + )); } match cli.command { diff --git a/src/web.rs b/src/web.rs index f42a236..22a322e 100644 --- a/src/web.rs +++ b/src/web.rs @@ -23,15 +23,17 @@ struct SharedState { struct WebState { base_seed: u64, speed: Speed, + demo: bool, next_id: usize, instances: Vec, } impl WebState { - fn new(base_seed: u64, speed: Speed) -> Self { + fn new(base_seed: u64, speed: Speed, demo: bool) -> Self { Self { base_seed, speed, + demo, next_id: 0, instances: Vec::new(), } @@ -118,6 +120,11 @@ struct CreateSimulationResponse { id: usize, } +#[derive(Debug, Serialize)] +struct ConfigResponse { + demo: bool, +} + fn sim_type_label(sim_type: SimulationType) -> &'static str { match sim_type { SimulationType::Single => "Single Match", @@ -275,6 +282,18 @@ async fn sc_logo_jpg() -> impl Responder { .body(include_bytes!("../sc-logo.jpg").as_slice()) } +async fn api_config(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(), + }) + } + }; + HttpResponse::Ok().json(ConfigResponse { demo: guard.demo }) +} + async fn api_teams() -> impl Responder { let items: Vec = TEAMS .iter() @@ -340,6 +359,13 @@ async fn api_create_simulation( }); }; + // Demo mode: FIFO rotation - remove oldest if at limit + const DEMO_MAX_INSTANCES: usize = 6; + if guard.demo && guard.instances.len() >= DEMO_MAX_INSTANCES { + // Remove the oldest simulation (first in the vec) + guard.instances.remove(0); + } + let id = guard.next_id; let seed = guard.next_seed(); let auto_fill = payload.auto_fill.unwrap_or(true); @@ -399,6 +425,13 @@ async fn api_clone_simulation( }); }; + // Demo mode: FIFO rotation - remove oldest if at limit + const DEMO_MAX_INSTANCES: usize = 6; + if guard.demo && guard.instances.len() >= DEMO_MAX_INSTANCES { + // Remove the oldest simulation (first in the vec) + guard.instances.remove(0); + } + let new_id = guard.next_id; let new_seed = guard.next_seed(); let clone = existing.clone_as(new_id, new_seed); @@ -422,6 +455,13 @@ async fn api_delete_simulation( } }; + // Disable manual deletion in demo mode + if guard.demo { + return HttpResponse::Forbidden().json(ErrorDto { + error: "Manual deletion is disabled in demo mode".to_string(), + }); + } + if guard.remove_simulation(id) { return HttpResponse::NoContent().finish(); } @@ -463,9 +503,9 @@ async fn api_export_csv(path: web::Path, state: web::Data) - .body(csv) } -pub fn run_web_server(base_seed: u64, speed: Speed, listen_open: bool) -> io::Result<()> { +pub fn run_web_server(base_seed: u64, speed: Speed, listen_open: bool, demo: bool) -> io::Result<()> { let shared = SharedState { - inner: Arc::new(Mutex::new(WebState::new(base_seed, speed))), + inner: Arc::new(Mutex::new(WebState::new(base_seed, speed, demo))), }; let ticker = shared.clone(); @@ -506,6 +546,7 @@ pub fn run_web_server(base_seed: u64, speed: Speed, listen_open: bool) -> io::Re .route("/sc-logo.jpg", web::get().to(sc_logo_jpg)) .service( web::scope("/api") + .route("/config", web::get().to(api_config)) .route("/teams", web::get().to(api_teams)) .route("/simulations", web::get().to(api_list_simulations)) .route("/simulations", web::post().to(api_create_simulation))