Added demo mode

This commit is contained in:
markmental 2026-02-13 14:44:39 -05:00
commit 513d1162ed
7 changed files with 103 additions and 9 deletions

2
Cargo.lock generated
View file

@ -1424,7 +1424,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "soccercloud" name = "soccercloud"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"clap", "clap",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "soccercloud" name = "soccercloud"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -1,5 +1,17 @@
# DEVLOG # 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 ## 2026-02-12 - v0.1.0 Initial Public Release
### Scope completed ### Scope completed

21
data.js
View file

@ -3,6 +3,7 @@ const state = {
simulations: [], simulations: [],
selectedDetailId: null, selectedDetailId: null,
pollHandle: null, pollHandle: null,
demo: false,
}; };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@ -148,10 +149,11 @@ function getCreatePayload() {
} }
function cardActions(sim) { function cardActions(sim) {
const deleteButton = state.demo ? "" : `<button class="btn warn" data-action="delete" data-id="${sim.id}">Delete</button>`;
const common = ` const common = `
<button class="btn secondary" data-action="view" data-id="${sim.id}">View</button> <button class="btn secondary" data-action="view" data-id="${sim.id}">View</button>
<button class="btn secondary" data-action="clone" data-id="${sim.id}">Clone</button> <button class="btn secondary" data-action="clone" data-id="${sim.id}">Clone</button>
<button class="btn warn" data-action="delete" data-id="${sim.id}">Delete</button> ${deleteButton}
`; `;
if (sim.status === "pending") { if (sim.status === "pending") {
@ -418,11 +420,24 @@ async function boot() {
initThemeControls(); initThemeControls();
bindEvents(); bindEvents();
try { 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"); state.teams = await request("/api/teams");
renderTeamSelectors(); renderTeamSelectors();
await refreshSimulations(); await refreshSimulations();
setStatus("Connected to SoccerCloud backend on port 9009."); setStatus(`Connected to SoccerCloud backend on port 9009.${state.demo ? " (Demo mode)" : ""}`);
} catch (error) { } catch (error) {
setStatus(`Startup failed: ${error.message}`); setStatus(`Startup failed: ${error.message}`);
return; return;

View file

@ -221,6 +221,21 @@
animation: rise 420ms ease both; 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 { .eyebrow {
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
@ -609,6 +624,7 @@
<main class="app"> <main class="app">
<section class="hero"> <section class="hero">
<article class="hero-card"> <article class="hero-card">
<div class="demo-banner" id="demoBanner">DEMO MODE - Limited to 6 simulations with auto-rotation. Manual deletion disabled.</div>
<img class="hero-logo" src="sc-logo.jpg" alt="SoccerCloud logo" /> <img class="hero-logo" src="sc-logo.jpg" alt="SoccerCloud logo" />
<p class="eyebrow">Rust Backend</p> <p class="eyebrow">Rust Backend</p>
<h1>SoccerCloud Web Control Room</h1> <h1>SoccerCloud Web Control Room</h1>

View file

@ -35,6 +35,9 @@ struct Cli {
#[arg(long, global = true)] #[arg(long, global = true)]
listen_open: bool, listen_open: bool,
#[arg(long, global = true)]
demo: bool,
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
} }
@ -93,7 +96,14 @@ fn main() -> io::Result<()> {
"--web cannot be combined with subcommands", "--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 { match cli.command {

View file

@ -23,15 +23,17 @@ struct SharedState {
struct WebState { struct WebState {
base_seed: u64, base_seed: u64,
speed: Speed, speed: Speed,
demo: bool,
next_id: usize, next_id: usize,
instances: Vec<SimulationInstance>, instances: Vec<SimulationInstance>,
} }
impl WebState { impl WebState {
fn new(base_seed: u64, speed: Speed) -> Self { fn new(base_seed: u64, speed: Speed, demo: bool) -> Self {
Self { Self {
base_seed, base_seed,
speed, speed,
demo,
next_id: 0, next_id: 0,
instances: Vec::new(), instances: Vec::new(),
} }
@ -118,6 +120,11 @@ struct CreateSimulationResponse {
id: usize, id: usize,
} }
#[derive(Debug, Serialize)]
struct ConfigResponse {
demo: bool,
}
fn sim_type_label(sim_type: SimulationType) -> &'static str { fn sim_type_label(sim_type: SimulationType) -> &'static str {
match sim_type { match sim_type {
SimulationType::Single => "Single Match", SimulationType::Single => "Single Match",
@ -275,6 +282,18 @@ async fn sc_logo_jpg() -> impl Responder {
.body(include_bytes!("../sc-logo.jpg").as_slice()) .body(include_bytes!("../sc-logo.jpg").as_slice())
} }
async fn api_config(state: web::Data<SharedState>) -> 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 { async fn api_teams() -> impl Responder {
let items: Vec<TeamDto> = TEAMS let items: Vec<TeamDto> = TEAMS
.iter() .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 id = guard.next_id;
let seed = guard.next_seed(); let seed = guard.next_seed();
let auto_fill = payload.auto_fill.unwrap_or(true); 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_id = guard.next_id;
let new_seed = guard.next_seed(); let new_seed = guard.next_seed();
let clone = existing.clone_as(new_id, new_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) { if guard.remove_simulation(id) {
return HttpResponse::NoContent().finish(); return HttpResponse::NoContent().finish();
} }
@ -463,9 +503,9 @@ async fn api_export_csv(path: web::Path<usize>, state: web::Data<SharedState>) -
.body(csv) .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 { 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(); 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)) .route("/sc-logo.jpg", web::get().to(sc_logo_jpg))
.service( .service(
web::scope("/api") web::scope("/api")
.route("/config", web::get().to(api_config))
.route("/teams", web::get().to(api_teams)) .route("/teams", web::get().to(api_teams))
.route("/simulations", web::get().to(api_list_simulations)) .route("/simulations", web::get().to(api_list_simulations))
.route("/simulations", web::post().to(api_create_simulation)) .route("/simulations", web::post().to(api_create_simulation))