diff --git a/crates/bitcell-admin/README.md b/crates/bitcell-admin/README.md index 71a59e6..fc2327d 100644 --- a/crates/bitcell-admin/README.md +++ b/crates/bitcell-admin/README.md @@ -162,14 +162,23 @@ bitcell-admin/ ## Security Considerations -⚠️ **IMPORTANT**: The admin console provides powerful administrative capabilities. In production: - -1. **Enable authentication** before exposing to network -2. **Use HTTPS/TLS** for encrypted communication -3. **Restrict access** via firewall rules or VPN -4. **Use strong passwords** and rotate regularly -5. **Enable audit logging** for all administrative actions -6. **Limit API rate limits** to prevent abuse +⚠️ **CRITICAL SECURITY WARNING** ⚠️ + +**NO AUTHENTICATION IS CURRENTLY IMPLEMENTED** + +The admin console currently allows **unrestricted access** to all endpoints. This is a **critical security vulnerability**. + +**DO NOT expose this admin console to any network (including localhost) in production without implementing authentication first.** + +For production deployments, you MUST: + +1. **Implement authentication** before exposing to any network +2. **Use HTTPS/TLS** for all communication (never HTTP in production) +3. **Restrict network access** via firewall rules, VPN, or IP allowlisting +4. **Use strong passwords** and rotate them regularly +5. **Enable comprehensive audit logging** for all administrative actions +6. **Implement API rate limiting** to prevent abuse +7. **Run with least-privilege** user accounts (never as root) ## Development diff --git a/crates/bitcell-admin/src/api/metrics.rs b/crates/bitcell-admin/src/api/metrics.rs index b1d3acb..ff368c2 100644 --- a/crates/bitcell-admin/src/api/metrics.rs +++ b/crates/bitcell-admin/src/api/metrics.rs @@ -78,13 +78,9 @@ pub async fn get_metrics( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; - // Calculate system metrics (placeholder - would normally come from node metrics or system stats) - let uptime_seconds = if let Some(first_node) = aggregated.node_metrics.first() { - let duration = chrono::Utc::now().signed_duration_since(first_node.last_updated); - duration.num_seconds().max(0) as u64 - } else { - 0 - }; + // Calculate system metrics + // TODO: Track actual node start times to compute real uptime + let uptime_seconds = 0u64; // Placeholder - requires node start time tracking let response = MetricsResponse { chain: ChainMetrics { @@ -100,20 +96,20 @@ pub async fn get_metrics( total_peers: aggregated.total_nodes * 10, // Estimate bytes_sent: aggregated.bytes_sent, bytes_received: aggregated.bytes_received, - messages_sent: 0, // TODO: Add to node metrics - messages_received: 0, // TODO: Add to node metrics + messages_sent: 0, // TODO: Requires adding message_sent to node metrics + messages_received: 0, // TODO: Requires adding message_received to node metrics }, ebsl: EbslMetrics { active_miners: aggregated.active_miners, banned_miners: aggregated.banned_miners, - average_trust_score: 0.85, // TODO: Calculate from actual data - total_slashing_events: 0, // TODO: Add to node metrics + average_trust_score: 0.85, // TODO: Requires adding trust scores to node metrics + total_slashing_events: 0, // TODO: Requires adding slashing events to node metrics }, system: SystemMetrics { uptime_seconds, - cpu_usage: 0.0, // TODO: Add system metrics collection - memory_usage_mb: 0, // TODO: Add system metrics collection - disk_usage_mb: 0, // TODO: Add system metrics collection + cpu_usage: 0.0, // TODO: Requires system metrics collection (e.g., sysinfo crate) + memory_usage_mb: 0, // TODO: Requires system metrics collection + disk_usage_mb: 0, // TODO: Requires system metrics collection }, }; diff --git a/crates/bitcell-admin/src/api/nodes.rs b/crates/bitcell-admin/src/api/nodes.rs index f5c73ff..6cb3cd9 100644 --- a/crates/bitcell-admin/src/api/nodes.rs +++ b/crates/bitcell-admin/src/api/nodes.rs @@ -32,6 +32,19 @@ pub struct StartNodeRequest { pub config: Option, } +/// Validate node ID format (alphanumeric, hyphens, and underscores only) +fn validate_node_id(id: &str) -> Result<(), (StatusCode, Json)> { + if id.is_empty() || !id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Invalid node ID format".to_string(), + }), + )); + } + Ok(()) +} + /// List all registered nodes pub async fn list_nodes( State(state): State>, @@ -47,6 +60,8 @@ pub async fn get_node( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, Json)> { + validate_node_id(&id)?; + match state.process.get_node(&id) { Some(node) => Ok(Json(NodeResponse { node })), None => Err(( @@ -62,8 +77,21 @@ pub async fn get_node( pub async fn start_node( State(state): State>, Path(id): Path, - Json(_req): Json, + Json(req): Json, ) -> Result, (StatusCode, Json)> { + validate_node_id(&id)?; + + // Config is not supported yet + if req.config.is_some() { + tracing::warn!("Node '{}': Rejected start request with unsupported config", id); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "Custom config is not supported yet".to_string(), + }), + )); + } + match state.process.start_node(&id) { Ok(node) => { tracing::info!("Started node '{}' successfully", id); @@ -83,6 +111,8 @@ pub async fn stop_node( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, Json)> { + validate_node_id(&id)?; + match state.process.stop_node(&id) { Ok(node) => { tracing::info!("Stopped node '{}' successfully", id); diff --git a/crates/bitcell-admin/src/api/setup.rs b/crates/bitcell-admin/src/api/setup.rs index e9dccd0..92ef905 100644 --- a/crates/bitcell-admin/src/api/setup.rs +++ b/crates/bitcell-admin/src/api/setup.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::AppState; -use crate::setup::NodeEndpoint; +use crate::setup::{NodeEndpoint, SETUP_FILE_PATH}; #[derive(Debug, Serialize)] pub struct SetupStatusResponse { @@ -68,7 +68,7 @@ pub async fn add_node( state.setup.add_node(node.clone()); // Save setup state - let setup_path = std::path::PathBuf::from(".bitcell/admin/setup.json"); + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); state.setup.save_to_file(&setup_path) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; @@ -87,7 +87,7 @@ pub async fn set_config_path( state.setup.set_config_path(path.clone()); // Save setup state - let setup_path = std::path::PathBuf::from(".bitcell/admin/setup.json"); + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); state.setup.save_to_file(&setup_path) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; @@ -101,17 +101,30 @@ pub async fn set_data_dir( ) -> Result, (StatusCode, Json)> { let path = std::path::PathBuf::from(&req.path); - // Create directory if it doesn't exist + // Create directory if it doesn't exist with restrictive permissions std::fs::create_dir_all(&path) .map_err(|e| ( StatusCode::INTERNAL_SERVER_ERROR, Json(format!("Failed to create data directory: {}", e)) ))?; + // Set restrictive permissions on Unix systems (0700 - owner only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o700); + std::fs::set_permissions(&path, permissions) + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(format!("Failed to set directory permissions: {}", e)) + ))?; + tracing::info!("Set data directory permissions to 0700 (owner only)"); + } + state.setup.set_data_dir(path); // Save setup state - let setup_path = std::path::PathBuf::from(".bitcell/admin/setup.json"); + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); state.setup.save_to_file(&setup_path) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; @@ -125,7 +138,7 @@ pub async fn complete_setup( state.setup.mark_initialized(); // Save setup state - let setup_path = std::path::PathBuf::from(".bitcell/admin/setup.json"); + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); state.setup.save_to_file(&setup_path) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; diff --git a/crates/bitcell-admin/src/api/test.rs b/crates/bitcell-admin/src/api/test.rs index 09c1173..b8c3819 100644 --- a/crates/bitcell-admin/src/api/test.rs +++ b/crates/bitcell-admin/src/api/test.rs @@ -114,18 +114,16 @@ pub async fn run_battle_test( let (outcome, energy_a, energy_b) = tokio::task::spawn_blocking(move || { // Simulate the battle - let outcome = battle.simulate() - .map_err(|e| format!("Battle simulation error: {:?}", e))?; + let outcome = battle.simulate(); // Get final grid to measure energies let final_grid = battle.final_grid(); let (energy_a, energy_b) = battle.measure_regional_energy(&final_grid); - Ok::<_, String>((outcome, energy_a, energy_b)) + (outcome, energy_a, energy_b) }) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(format!("Task join error: {}", e))))? - .map_err(|e: String| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(format!("Task join error: {}", e))))?; let duration = start.elapsed(); @@ -222,8 +220,7 @@ pub async fn run_battle_visualization( // Run simulation and capture frames let (outcome, frames) = tokio::task::spawn_blocking(move || { // Get outcome - let outcome = battle.simulate() - .map_err(|e| format!("Battle simulation error: {:?}", e))?; + let outcome = battle.simulate(); // Get grid states at sample steps let grids = battle.grid_states(&sample_steps); @@ -243,11 +240,10 @@ pub async fn run_battle_visualization( }); } - Ok::<_, String>((outcome, frames)) + (outcome, frames) }) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(format!("Task join error: {}", e))))? - .map_err(|e: String| (StatusCode::INTERNAL_SERVER_ERROR, Json(e)))?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(format!("Task join error: {}", e))))?; let winner = match outcome { BattleOutcome::AWins => "glider_a".to_string(), diff --git a/crates/bitcell-admin/src/lib.rs b/crates/bitcell-admin/src/lib.rs index a75f0cd..9fb0ccd 100644 --- a/crates/bitcell-admin/src/lib.rs +++ b/crates/bitcell-admin/src/lib.rs @@ -30,6 +30,7 @@ pub use api::AdminApi; pub use deployment::DeploymentManager; pub use config::ConfigManager; pub use process::ProcessManager; +pub use setup::SETUP_FILE_PATH; /// Administrative console server pub struct AdminConsole { @@ -50,7 +51,7 @@ impl AdminConsole { let setup = Arc::new(setup::SetupManager::new()); // Try to load setup state from default location - let setup_path = std::path::PathBuf::from(".bitcell/admin/setup.json"); + let setup_path = std::path::PathBuf::from(SETUP_FILE_PATH); if let Err(e) = setup.load_from_file(&setup_path) { tracing::warn!("Failed to load setup state: {}", e); } @@ -112,7 +113,9 @@ impl AdminConsole { // Static files .nest_service("/static", ServeDir::new("static")) - // CORS + // CORS - WARNING: Permissive CORS allows requests from any origin. + // This is only suitable for local development. For production, + // configure specific allowed origins to prevent CSRF attacks. .layer(CorsLayer::permissive()) // State diff --git a/crates/bitcell-admin/src/main.rs b/crates/bitcell-admin/src/main.rs index df01b5c..4a277e2 100644 --- a/crates/bitcell-admin/src/main.rs +++ b/crates/bitcell-admin/src/main.rs @@ -1,6 +1,6 @@ //! BitCell Admin Console - Main Entry Point -use bitcell_admin::{AdminConsole, process::{ProcessManager, NodeConfig}, api::NodeType}; +use bitcell_admin::AdminConsole; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -23,47 +23,11 @@ async fn main() -> Result<(), Box> { .parse()?; let console = AdminConsole::new(addr); - let process_mgr = console.process_manager(); - // Register some sample nodes for demonstration - register_sample_nodes(&process_mgr); - - tracing::info!("Admin console ready - registered {} nodes", process_mgr.list_nodes().len()); + tracing::info!("Admin console ready"); tracing::info!("Dashboard available at http://{}", addr); console.serve().await?; Ok(()) } - -fn register_sample_nodes(process: &ProcessManager) { - // Register sample validator nodes - for i in 1..=3 { - let config = NodeConfig { - node_type: NodeType::Validator, - data_dir: format!("/tmp/bitcell/validator-{}", i), - port: 9000 + i as u16, - rpc_port: 10000 + i as u16, - log_level: "info".to_string(), - network: "testnet".to_string(), - }; - - process.register_node(format!("validator-{}", i), config); - tracing::info!("Registered validator-{}", i); - } - - // Register sample miner nodes - for i in 1..=2 { - let config = NodeConfig { - node_type: NodeType::Miner, - data_dir: format!("/tmp/bitcell/miner-{}", i), - port: 9100 + i as u16, - rpc_port: 10100 + i as u16, - log_level: "info".to_string(), - network: "testnet".to_string(), - }; - - process.register_node(format!("miner-{}", i), config); - tracing::info!("Registered miner-{}", i); - } -} diff --git a/crates/bitcell-admin/src/metrics_client.rs b/crates/bitcell-admin/src/metrics_client.rs index ac88996..6ea3f75 100644 --- a/crates/bitcell-admin/src/metrics_client.rs +++ b/crates/bitcell-admin/src/metrics_client.rs @@ -31,9 +31,9 @@ impl MetricsClient { pub fn new() -> Self { Self { client: reqwest::Client::builder() - .timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) .build() - .unwrap(), + .expect("Failed to build HTTP client for metrics"), } } @@ -58,6 +58,9 @@ impl MetricsClient { } /// Parse Prometheus metrics format + /// NOTE: This is a basic parser that only handles simple "metric_name value" format. + /// It does NOT support metric labels (e.g., metric{label="value"}). + /// For production use, consider using a proper Prometheus parsing library. fn parse_prometheus_metrics(&self, node_id: &str, endpoint: &str, text: &str) -> Result { let mut metrics = HashMap::new(); diff --git a/crates/bitcell-admin/src/process.rs b/crates/bitcell-admin/src/process.rs index 6efb554..0b46234 100644 --- a/crates/bitcell-admin/src/process.rs +++ b/crates/bitcell-admin/src/process.rs @@ -69,6 +69,8 @@ impl ProcessManager { } // Build command to start node + // NOTE: Uses 'cargo run' which is suitable for development only. + // For production deployments, use a compiled binary path instead. let mut cmd = Command::new("cargo"); cmd.arg("run") .arg("-p") @@ -93,7 +95,10 @@ impl ProcessManager { // Spawn the process let child = cmd.spawn() - .map_err(|e| format!("Failed to spawn process: {}", e))?; + .map_err(|e| { + tracing::error!("Failed to spawn process for node '{}': {:?}", id, e); + "Failed to start node process".to_string() + })?; node.process = Some(child); node.info.status = NodeStatus::Running; @@ -116,10 +121,20 @@ impl ProcessManager { // Try graceful shutdown first #[cfg(unix)] { - use std::os::unix::process::CommandExt; let pid = process.id(); - unsafe { - libc::kill(pid as i32, libc::SIGTERM); + // SAFETY: We call libc::kill to send SIGTERM to the child process. + // The PID is obtained from process.id(), which should be valid for a running child. + // However, the process may have already exited, or permissions may be insufficient. + // We check the return value for errors. + let res = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + if res != 0 { + let errno = std::io::Error::last_os_error(); + tracing::warn!( + "Failed to send SIGTERM to process {} for node '{}': {}", + pid, + id, + errno + ); } // Wait up to 5 seconds for graceful shutdown diff --git a/crates/bitcell-admin/src/setup.rs b/crates/bitcell-admin/src/setup.rs index c690dc5..a8ec60c 100644 --- a/crates/bitcell-admin/src/setup.rs +++ b/crates/bitcell-admin/src/setup.rs @@ -4,6 +4,9 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::RwLock; +/// Default setup file path +pub const SETUP_FILE_PATH: &str = ".bitcell/admin/setup.json"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SetupState { pub initialized: bool, diff --git a/crates/bitcell-admin/src/web/dashboard.rs b/crates/bitcell-admin/src/web/dashboard.rs index 07585d5..18497b7 100644 --- a/crates/bitcell-admin/src/web/dashboard.rs +++ b/crates/bitcell-admin/src/web/dashboard.rs @@ -771,12 +771,41 @@ pub async fn index() -> impl IntoResponse {
-

🖥️ Registered Nodes

+
+

🖥️ Registered Nodes

+ +
Loading nodes...
+ +
+
+
+

Deploy New Nodes

+

Deploy new BitCell nodes to your network

+
+
+ + +
+
+ + +
+
+ + +
+
+
+

⚔️ Cellular Automata Battle Visualization

@@ -1006,6 +1035,60 @@ pub async fn index() -> impl IntoResponse { window.location.reload(); } + function showDeployDialog() { + document.getElementById('deploy-overlay').classList.add('active'); + } + + function closeDeployDialog() { + document.getElementById('deploy-overlay').classList.remove('active'); + } + + async function deployNodes() { + const nodeType = document.getElementById('deploy-node-type').value; + const count = parseInt(document.getElementById('deploy-count').value); + + if (isNaN(count) || count < 1 || count > 10) { + alert('Please enter a valid number between 1 and 10'); + return; + } + + try { + const response = await fetch('/api/deployment/deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + node_type: nodeType, + count: count + }) + }); + + if (!response.ok) { + let errorMessage = 'Deployment failed'; + try { + const error = await response.json(); + errorMessage = error.error || error.message || errorMessage; + } catch (e) { + const text = await response.text(); + // Avoid showing large HTML blobs; use a generic message if text looks like HTML + if (text && !/^ impl IntoResponse { async function startNode(id) { try { - await fetch(`/api/nodes/${id}/start`, { method: 'POST' }); + const response = await fetch(`/api/nodes/${id}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: null }) + }); + + if (!response.ok) { + let errorMessage = 'Failed to start node'; + try { + const error = await response.json(); + errorMessage = error.error || errorMessage; + } catch (e) { + // If JSON parsing fails, use default message + } + throw new Error(errorMessage); + } + updateNodes(); } catch (error) { console.error('Failed to start node:', error); + alert('Failed to start node: ' + error.message); } } async function stopNode(id) { try { - await fetch(`/api/nodes/${id}/stop`, { method: 'POST' }); + const response = await fetch(`/api/nodes/${id}/stop`, { method: 'POST' }); + + if (!response.ok) { + let errorMessage = 'Failed to stop node'; + try { + const error = await response.json(); + errorMessage = error.error || errorMessage; + } catch (e) { + // If JSON parsing fails, use default message + } + throw new Error(errorMessage); + } + updateNodes(); } catch (error) { console.error('Failed to stop node:', error); + alert('Failed to stop node: ' + error.message); } } diff --git a/crates/bitcell-ca/src/battle.rs b/crates/bitcell-ca/src/battle.rs index 60e139c..bb46a72 100644 --- a/crates/bitcell-ca/src/battle.rs +++ b/crates/bitcell-ca/src/battle.rs @@ -3,9 +3,8 @@ //! Simulates CA evolution with two gliders and determines the winner. use crate::glider::Glider; -use crate::grid::{Grid, Position, GRID_SIZE}; +use crate::grid::{Grid, Position}; use crate::rules::evolve_n_steps; -use crate::{Error, Result}; use serde::{Deserialize, Serialize}; /// Number of steps to simulate a battle @@ -67,7 +66,7 @@ impl Battle { } /// Simulate the battle and return the outcome - pub fn simulate(&self) -> Result { + pub fn simulate(&self) -> BattleOutcome { let initial_grid = self.setup_grid(); let final_grid = evolve_n_steps(&initial_grid, self.steps); @@ -82,7 +81,7 @@ impl Battle { BattleOutcome::Tie }; - Ok(outcome) + outcome } /// Measure energy in regions around spawn points @@ -130,20 +129,51 @@ impl Battle { evolve_n_steps(&initial, self.steps) } - /// Get grid states at specific steps for visualization - /// Returns a vector of grids at the requested step intervals + /// Get grid states at specific steps for visualization. + /// + /// Returns a vector of grids at the requested step intervals in the same order + /// as the input `sample_steps` array. + /// Steps that exceed `self.steps` are silently skipped. + /// + /// # Performance Note + /// This implementation sorts steps internally for incremental evolution efficiency, + /// but returns grids in the original order requested. + /// + /// # Memory Overhead + /// Each grid clone can be expensive for large grids (e.g., 1024×1024 grids). + /// Requesting many sample steps will require storing multiple grid copies in memory. + /// For example, 100 sample steps could require several hundred MB of memory. pub fn grid_states(&self, sample_steps: &[usize]) -> Vec { - let mut grids = Vec::new(); let initial = self.setup_grid(); - for &step in sample_steps { - if step <= self.steps { - let grid = evolve_n_steps(&initial, step); - grids.push(grid); + // Filter and create (index, step) pairs to preserve original order + let mut indexed_steps: Vec<(usize, usize)> = sample_steps.iter() + .enumerate() + .filter(|(_, &step)| step <= self.steps) + .map(|(idx, &step)| (idx, step)) + .collect(); + + // Sort by step for efficient incremental evolution + indexed_steps.sort_unstable_by_key(|(_, step)| *step); + + // Evolve grids in sorted order + let mut evolved_grids = Vec::with_capacity(indexed_steps.len()); + let mut current_grid = initial; + let mut prev_step = 0; + + for (original_idx, step) in &indexed_steps { + let steps_to_evolve = step - prev_step; + // If steps_to_evolve is 0 (e.g., for step 0), the grid remains unchanged + if steps_to_evolve > 0 { + current_grid = evolve_n_steps(¤t_grid, steps_to_evolve); } + evolved_grids.push((*original_idx, current_grid.clone())); + prev_step = *step; } - grids + // Sort back to original order and extract grids + evolved_grids.sort_unstable_by_key(|(idx, _)| *idx); + evolved_grids.into_iter().map(|(_, grid)| grid).collect() } } @@ -180,7 +210,7 @@ mod tests { // Short battle for testing let battle = Battle::with_steps(glider_a, glider_b, 100); - let outcome = battle.simulate().unwrap(); + let outcome = battle.simulate(); // With higher initial energy, A should have advantage // (though CA evolution can be chaotic) @@ -193,7 +223,7 @@ mod tests { let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B); let battle = Battle::with_steps(glider_a, glider_b, 50); - let outcome = battle.simulate().unwrap(); + let outcome = battle.simulate(); // Identical gliders should trend toward tie (though not guaranteed due to asymmetry) // Just verify it completes @@ -209,7 +239,7 @@ mod tests { let glider_b = Glider::new(GliderPattern::Standard, SPAWN_B); let battle = Battle::with_steps(glider_a, glider_b, 100); - let outcome = battle.simulate().unwrap(); + let outcome = battle.simulate(); // Heavier pattern has more cells and energy // Should generally win, but CA is chaotic diff --git a/crates/bitcell-ca/src/grid.rs b/crates/bitcell-ca/src/grid.rs index 8d8432a..b429b6e 100644 --- a/crates/bitcell-ca/src/grid.rs +++ b/crates/bitcell-ca/src/grid.rs @@ -139,9 +139,32 @@ impl Grid { } } - /// Get a downsampled view of the grid for visualization - /// Returns a smaller grid by taking max energy in each block + /// Get a downsampled view of the grid for visualization. + /// + /// Uses max pooling to downsample the grid: divides the grid into blocks + /// and returns the maximum energy value in each block. This is useful for + /// visualizing large grids at lower resolutions. + /// + /// # Arguments + /// * `target_size` - The desired output grid size (must be > 0 and <= GRID_SIZE) + /// + /// # Returns + /// A 2D vector of size `target_size × target_size` containing max energy values. + /// + /// # Panics + /// Panics if `target_size` is 0 or greater than `GRID_SIZE`. + /// + /// # Note + /// When `GRID_SIZE` is not evenly divisible by `target_size`, some cells near + /// the edges may not be sampled. For example, with `GRID_SIZE=1024` and + /// `target_size=100`, `block_size=10`, so only cells from indices 0-999 are + /// sampled, leaving rows/columns 1000-1023 unsampled. This is acceptable for + /// visualization purposes where approximate representation is sufficient. pub fn downsample(&self, target_size: usize) -> Vec> { + if target_size == 0 || target_size > GRID_SIZE { + panic!("target_size must be between 1 and {}", GRID_SIZE); + } + let block_size = GRID_SIZE / target_size; let mut result = vec![vec![0u8; target_size]; target_size]; diff --git a/tests/tournament_integration.rs b/tests/tournament_integration.rs index ac70ba6..9524429 100644 --- a/tests/tournament_integration.rs +++ b/tests/tournament_integration.rs @@ -50,10 +50,10 @@ fn test_full_tournament_flow() { let glider_b = Glider::new(reveals[1].pattern, Position::new(800, 800)); let battle = Battle::new(glider_a, glider_b); - let outcome = battle.simulate().expect("Battle should complete"); + let outcome = battle.simulate(); - // Winner should be one of the two participants - assert!(outcome.winner == 0 || outcome.winner == 1); + // Outcome should be one of the three valid results + assert!(matches!(outcome, bitcell_ca::BattleOutcome::AWins | bitcell_ca::BattleOutcome::BWins | bitcell_ca::BattleOutcome::Tie)); } #[test]