This is an automated email from the ASF dual-hosted git repository.

piotr pushed a commit to branch simulator_ui
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/simulator_ui by this push:
     new 8d313ed66 Add nodes selector
8d313ed66 is described below

commit 8d313ed665330044ae6ac8ed42c16b04b0de36d5
Author: spetz <[email protected]>
AuthorDate: Tue Apr 14 15:36:08 2026 +0200

    Add nodes selector
---
 core/simulator_ui/src/components.rs    |  21 ++
 core/simulator_ui/src/helpers.rs       |  12 +-
 core/simulator_ui/src/input.rs         |  39 +++-
 core/simulator_ui/src/main.rs          |  94 ++++++--
 core/simulator_ui/src/panels.rs        |  19 +-
 core/simulator_ui/src/resources.rs     |  91 +++++---
 core/simulator_ui/src/setup.rs         | 409 +++++++++++++++++++++++++++++----
 core/simulator_ui/src/simulation.rs    |  30 ++-
 core/simulator_ui/src/theme.rs         |   2 +-
 core/simulator_ui/src/tracing_layer.rs |  17 +-
 core/simulator_ui/src/visuals.rs       |  12 +-
 11 files changed, 621 insertions(+), 125 deletions(-)

diff --git a/core/simulator_ui/src/components.rs 
b/core/simulator_ui/src/components.rs
index f1c536644..4b3669f6a 100644
--- a/core/simulator_ui/src/components.rs
+++ b/core/simulator_ui/src/components.rs
@@ -226,3 +226,24 @@ pub(crate) struct Lightning {
     pub(crate) lifetime: f32,
     pub(crate) max_lifetime: f32,
 }
+
+#[derive(Component)]
+pub(crate) struct SelectionScreen;
+
+#[derive(Component)]
+pub(crate) struct SelectionCard {
+    pub(crate) index: usize,
+}
+
+#[derive(Component)]
+pub(crate) struct SelectionNumber {
+    pub(crate) index: usize,
+}
+
+#[derive(Component)]
+pub(crate) struct SelectionLabel {
+    pub(crate) index: usize,
+}
+
+#[derive(Component)]
+pub(crate) struct SelectionTitle;
diff --git a/core/simulator_ui/src/helpers.rs b/core/simulator_ui/src/helpers.rs
index 01e7ad287..76e13f8e8 100644
--- a/core/simulator_ui/src/helpers.rs
+++ b/core/simulator_ui/src/helpers.rs
@@ -121,6 +121,8 @@ pub(crate) fn dog_name(id: u8) -> &'static str {
         2 => "Dash",
         3 => "Bolt",
         4 => "Flash",
+        5 => "Storm",
+        6 => "Blaze",
         _ => "Pup",
     }
 }
@@ -226,7 +228,11 @@ pub(crate) fn spawn_trail_dot(commands: &mut Commands, 
position: Vec2, color: Co
     ));
 }
 
-pub(crate) fn narrate_event(event: &CapturedSimEvent, tick: u64) -> 
Option<EventLogEntry> {
+pub(crate) fn narrate_event(
+    event: &CapturedSimEvent,
+    tick: u64,
+    replica_count: u8,
+) -> Option<EventLogEntry> {
     match event.sim_event.as_str() {
         "ClientRequestReceived" => {
             let replica_id = event.replica_id.unwrap_or(0) as u8;
@@ -414,9 +420,9 @@ pub(crate) fn narrate_event(event: &CapturedSimEvent, tick: 
u64) -> Option<Event
                 headline: format!("{name} started view change! (v{old_view} -> 
v{new_view})"),
                 detail: format!(
                     "{reason_text} The pack is reshuffling -- \
-                     the dog at position (view {new_view} mod {REPLICA_COUNT}) 
= R{} \
+                     the dog at position (view {new_view} mod {replica_count}) 
= R{} \
                      will become the new lead dog if quorum agrees.",
-                    new_view % REPLICA_COUNT as u64
+                    new_view % replica_count as u64
                 ),
                 color: NEON_MAGENTA,
             })
diff --git a/core/simulator_ui/src/input.rs b/core/simulator_ui/src/input.rs
index e9e50356f..e786f60a3 100644
--- a/core/simulator_ui/src/input.rs
+++ b/core/simulator_ui/src/input.rs
@@ -17,21 +17,55 @@
 
 use bevy::prelude::*;
 
+use crate::AppPhase;
 use crate::components::*;
 use crate::helpers::*;
 use crate::queries::FxParams;
 use crate::resources::*;
 use crate::theme::*;
 
+pub(crate) fn handle_selection_input(
+    keys: Res<ButtonInput<KeyCode>>,
+    mut state: ResMut<SelectionState>,
+    mut config: ResMut<ReplicaConfig>,
+    mut next_state: ResMut<NextState<AppPhase>>,
+) {
+    if keys.just_pressed(KeyCode::ArrowLeft) && state.index > 0 {
+        state.index -= 1;
+    }
+    if keys.just_pressed(KeyCode::ArrowRight) && state.index < 
PACK_OPTIONS.len() - 1 {
+        state.index += 1;
+    }
+
+    if keys.just_pressed(KeyCode::Digit3) || 
keys.just_pressed(KeyCode::Numpad3) {
+        state.index = 0;
+    }
+    if keys.just_pressed(KeyCode::Digit5) || 
keys.just_pressed(KeyCode::Numpad5) {
+        state.index = 1;
+    }
+    if keys.just_pressed(KeyCode::Digit7) || 
keys.just_pressed(KeyCode::Numpad7) {
+        state.index = 2;
+    }
+
+    if keys.just_pressed(KeyCode::Space) || keys.just_pressed(KeyCode::Enter) {
+        config.count = PACK_OPTIONS[state.index];
+        next_state.set(AppPhase::Simulating);
+    }
+}
+
+#[allow(clippy::too_many_arguments)]
 pub(crate) fn handle_keyboard_input(
     mut commands: Commands,
     keys: Res<ButtonInput<KeyCode>>,
     mut sim: NonSendMut<SimulationState>,
     mut event_log: ResMut<EventLog>,
     mut console: ResMut<GameConsole>,
+    config: Res<ReplicaConfig>,
     positions: Res<ReplicaPositions>,
     mut fx: FxParams,
 ) {
+    let replica_count = config.count;
+
     if keys.just_pressed(KeyCode::Space) {
         sim.playing = !sim.playing;
     }
@@ -50,7 +84,6 @@ pub(crate) fn handle_keyboard_input(
     if keys.just_pressed(KeyCode::KeyR) {
         sim.simulator.inject_client_request();
         if !sim.playing {
-            // Auto-step so the request gets processed even when paused
             for _ in 0..50 {
                 sim.simulator.step();
             }
@@ -107,7 +140,7 @@ pub(crate) fn handle_keyboard_input(
         fx.screen_flash.timer = SCREEN_FLASH_DURATION * 0.5;
         fx.screen_flash.color = NEON_CYAN;
         fx.screen_flash.intensity = 0.04;
-        for replica_id in 0..REPLICA_COUNT {
+        for replica_id in 0..replica_count {
             fx.replica_fx.revive[replica_id as usize] = 1.0;
             fx.replica_fx.healthy[replica_id as usize] = 0.6;
             trigger_replica_callout(&mut fx.replica_fx, replica_id, "HEALED!", 
NEON_CYAN, 1.2);
@@ -136,7 +169,7 @@ pub(crate) fn handle_keyboard_input(
     }
 
     if keys.just_pressed(KeyCode::KeyP) {
-        let alive: Vec<u8> = (0..REPLICA_COUNT)
+        let alive: Vec<u8> = (0..replica_count)
             .filter(|&replica_id| !sim.simulator.is_crashed(replica_id))
             .collect();
 
diff --git a/core/simulator_ui/src/main.rs b/core/simulator_ui/src/main.rs
index a5878a225..4104cdbd7 100644
--- a/core/simulator_ui/src/main.rs
+++ b/core/simulator_ui/src/main.rs
@@ -39,6 +39,13 @@ use resources::*;
 use theme::*;
 use tracing_layer::EventBuffer;
 
+#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
+pub(crate) enum AppPhase {
+    #[default]
+    Selecting,
+    Simulating,
+}
+
 fn main() {
     use tracing_layer::SimEventLayer;
     use tracing_subscriber::prelude::*;
@@ -46,18 +53,27 @@ fn main() {
 
     let event_buffer = EventBuffer::default();
     let raw_lines = tracing_layer::RawLineBuffer::default();
+    let raw_generation = tracing_layer::RawLineGeneration::default();
 
-    let filter =
+    let fmt_filter =
         EnvFilter::try_from_default_env().unwrap_or_else(|_| 
EnvFilter::new("warn,iggy.sim=debug"));
+    let sim_filter = EnvFilter::new("off,iggy.sim=debug");
 
     let fmt_layer = fmt::layer()
         .with_target(true)
         .with_ansi(true)
         .compact()
-        .with_filter(filter);
+        .with_filter(fmt_filter);
 
     tracing_subscriber::registry()
-        .with(SimEventLayer::new(event_buffer.clone(), raw_lines.clone()))
+        .with(
+            SimEventLayer::new(
+                event_buffer.clone(),
+                raw_lines.clone(),
+                raw_generation.clone(),
+            )
+            .with_filter(sim_filter),
+        )
         .with(fmt_layer)
         .init();
 
@@ -67,8 +83,9 @@ fn main() {
         bucket_capacity: 1,
     });
 
-    let simulation = SimulationState {
-        simulator: UiSimulator::new(REPLICA_COUNT, 42, event_buffer),
+    // Placeholder simulation — overwritten in OnEnter(Simulating) by 
reinit_simulation
+    let placeholder_sim = SimulationState {
+        simulator: UiSimulator::new(DEFAULT_REPLICA_COUNT, 42, 
event_buffer.clone()),
         playing: false,
         speed: 1.0,
         tick_accumulator: 0.0,
@@ -99,8 +116,14 @@ fn main() {
                 }),
         )
         .add_plugins(ShapePlugin)
+        .init_state::<AppPhase>()
         .insert_resource(ClearColor(BG_DARK))
-        .insert_non_send_resource(simulation)
+        .insert_non_send_resource(placeholder_sim)
+        .insert_resource(ReplicaConfig {
+            count: DEFAULT_REPLICA_COUNT,
+        })
+        .insert_resource(SelectionState::default())
+        .insert_resource(SharedBuffers { event_buffer })
         .insert_resource(ReplicaPositions::default())
         .insert_resource(ReplicaFxState::default())
         .insert_resource(AppFxState::default())
@@ -109,31 +132,68 @@ fn main() {
         .insert_resource(EventLog::default())
         .insert_resource(GameConsole {
             raw_lines,
+            raw_generation,
             open: false,
             slide: 0.0,
-            last_line_count: 0,
+            last_seen_generation: 0,
         })
         .add_systems(Startup, setup::setup_camera)
+        .add_systems(Startup, 
setup::setup_background.after(setup::setup_camera))
+        .add_systems(OnEnter(AppPhase::Selecting), 
setup::setup_selection_screen)
+        .add_systems(
+            Update,
+            (
+                input::handle_selection_input,
+                setup::update_selection_screen,
+            )
+                .run_if(in_state(AppPhase::Selecting)),
+        )
+        .add_systems(OnExit(AppPhase::Selecting), 
setup::despawn_selection_screen)
+        .add_systems(OnEnter(AppPhase::Simulating), setup::reinit_simulation)
         .add_systems(
-            Startup,
-            (setup::setup_world, setup::setup_hud).after(setup::setup_camera),
+            OnEnter(AppPhase::Simulating),
+            (setup::setup_simulation_world, 
setup::setup_hud).after(setup::reinit_simulation),
+        )
+        .add_systems(
+            Update,
+            
input::handle_keyboard_input.run_if(in_state(AppPhase::Simulating)),
         )
-        .add_systems(Update, input::handle_keyboard_input)
         .add_systems(Update, simulation::tick_simulation)
-        .add_systems(Update, visuals::update_replica_visuals)
-        .add_systems(Update, visuals::update_hud_text)
-        .add_systems(Update, visuals::update_pause_overlay)
-        .add_systems(Update, visuals::update_app_visuals)
-        .add_systems(Update, visuals::update_app_flow_particles)
+        .add_systems(
+            Update,
+            
visuals::update_replica_visuals.run_if(in_state(AppPhase::Simulating)),
+        )
+        .add_systems(
+            Update,
+            visuals::update_hud_text.run_if(in_state(AppPhase::Simulating)),
+        )
+        .add_systems(
+            Update,
+            
visuals::update_pause_overlay.run_if(in_state(AppPhase::Simulating)),
+        )
+        .add_systems(
+            Update,
+            visuals::update_app_visuals.run_if(in_state(AppPhase::Simulating)),
+        )
+        .add_systems(
+            Update,
+            
visuals::update_app_flow_particles.run_if(in_state(AppPhase::Simulating)),
+        )
         .add_systems(Update, visuals::update_message_particles)
         .add_systems(Update, visuals::update_trail_dots)
-        .add_systems(Update, visuals::update_link_lines)
+        .add_systems(
+            Update,
+            visuals::update_link_lines.run_if(in_state(AppPhase::Simulating)),
+        )
         .add_systems(Update, visuals::update_scan_lines)
         .add_systems(Update, visuals::update_commit_rings)
         .add_systems(Update, visuals::update_ambient_particles)
         .add_systems(Update, visuals::update_screen_flash)
         .add_systems(Update, visuals::update_speed_lines)
-        .add_systems(Update, visuals::update_track_ring)
+        .add_systems(
+            Update,
+            visuals::update_track_ring.run_if(in_state(AppPhase::Simulating)),
+        )
         .add_systems(Update, visuals::update_paw_bursts)
         .add_systems(Update, visuals::update_lightning)
         .add_systems(Update, panels::update_event_log_panel)
diff --git a/core/simulator_ui/src/panels.rs b/core/simulator_ui/src/panels.rs
index 02cf56218..8734dcc4d 100644
--- a/core/simulator_ui/src/panels.rs
+++ b/core/simulator_ui/src/panels.rs
@@ -151,17 +151,18 @@ pub(crate) fn update_game_console(
         return;
     }
 
-    let (line_count, lines) = {
-        let raw = console.raw_lines.lock().unwrap_or_else(|e| e.into_inner());
-        (
-            raw.len(),
-            raw.iter().rev().take(60).cloned().collect::<Vec<_>>(),
-        )
-    };
-    if line_count == console.last_line_count {
+    let current_generation = console
+        .raw_generation
+        .load(std::sync::atomic::Ordering::Relaxed);
+    if current_generation == console.last_seen_generation {
         return;
     }
-    console.last_line_count = line_count;
+    console.last_seen_generation = current_generation;
+
+    let lines = {
+        let raw = console.raw_lines.lock().unwrap_or_else(|e| e.into_inner());
+        raw.iter().rev().take(60).cloned().collect::<Vec<_>>()
+    };
 
     for (content_entity, children) in content_query.iter() {
         if let Some(children) = children {
diff --git a/core/simulator_ui/src/resources.rs 
b/core/simulator_ui/src/resources.rs
index 34c969ed5..36ae18632 100644
--- a/core/simulator_ui/src/resources.rs
+++ b/core/simulator_ui/src/resources.rs
@@ -15,14 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use bevy::prelude::*;
 use std::collections::VecDeque;
 use std::f32::consts::PI;
 
-use bevy::prelude::*;
-
 use crate::bridge::UiSimulator;
 use crate::theme::*;
-use crate::tracing_layer::RawLineBuffer;
+use crate::tracing_layer::{EventBuffer, RawLineBuffer, RawLineGeneration};
 use crate::types::Status;
 
 pub(crate) struct SimulationState {
@@ -37,14 +36,24 @@ pub(crate) struct SimulationState {
     pub(crate) frame_count: u64,
 }
 
+#[derive(Resource)]
+pub(crate) struct ReplicaConfig {
+    pub(crate) count: u8,
+}
+
+#[derive(Resource, Clone)]
+pub(crate) struct SharedBuffers {
+    pub(crate) event_buffer: EventBuffer,
+}
+
 #[derive(Resource)]
 pub(crate) struct ReplicaPositions(pub(crate) Vec<Vec2>);
 
-impl Default for ReplicaPositions {
-    fn default() -> Self {
-        let mut positions = Vec::with_capacity(REPLICA_COUNT as usize);
-        for idx in 0..REPLICA_COUNT {
-            let angle = 2.0 * PI * (idx as f32) / (REPLICA_COUNT as f32) - PI 
/ 2.0;
+impl ReplicaPositions {
+    pub(crate) fn new(count: u8) -> Self {
+        let mut positions = Vec::with_capacity(count as usize);
+        for idx in 0..count {
+            let angle = 2.0 * PI * (idx as f32) / (count as f32) - PI / 2.0;
             positions.push(Vec2::new(
                 CIRCLE_RADIUS * angle.cos(),
                 CIRCLE_RADIUS * angle.sin() + WORLD_CENTER_Y,
@@ -54,33 +63,46 @@ impl Default for ReplicaPositions {
     }
 }
 
+impl Default for ReplicaPositions {
+    fn default() -> Self {
+        Self::new(DEFAULT_REPLICA_COUNT)
+    }
+}
+
 #[derive(Resource)]
 pub(crate) struct ReplicaFxState {
-    pub(crate) kill: [f32; REPLICA_COUNT as usize],
-    pub(crate) revive: [f32; REPLICA_COUNT as usize],
-    pub(crate) healthy: [f32; REPLICA_COUNT as usize],
-    pub(crate) last_alive: [bool; REPLICA_COUNT as usize],
-    pub(crate) last_status: [Status; REPLICA_COUNT as usize],
-    pub(crate) callout_timer: [f32; REPLICA_COUNT as usize],
-    pub(crate) callout_text: [String; REPLICA_COUNT as usize],
-    pub(crate) callout_color: [Color; REPLICA_COUNT as usize],
+    pub(crate) kill: Vec<f32>,
+    pub(crate) revive: Vec<f32>,
+    pub(crate) healthy: Vec<f32>,
+    pub(crate) last_alive: Vec<bool>,
+    pub(crate) last_status: Vec<Status>,
+    pub(crate) callout_timer: Vec<f32>,
+    pub(crate) callout_text: Vec<String>,
+    pub(crate) callout_color: Vec<Color>,
 }
 
-impl Default for ReplicaFxState {
-    fn default() -> Self {
+impl ReplicaFxState {
+    pub(crate) fn new(count: u8) -> Self {
+        let n = count as usize;
         Self {
-            kill: [0.0; REPLICA_COUNT as usize],
-            revive: [0.0; REPLICA_COUNT as usize],
-            healthy: [0.0; REPLICA_COUNT as usize],
-            last_alive: [true; REPLICA_COUNT as usize],
-            last_status: [Status::Normal; REPLICA_COUNT as usize],
-            callout_timer: [0.0; REPLICA_COUNT as usize],
-            callout_text: std::array::from_fn(|_| String::new()),
-            callout_color: [COOL_WHITE; REPLICA_COUNT as usize],
+            kill: vec![0.0; n],
+            revive: vec![0.0; n],
+            healthy: vec![0.0; n],
+            last_alive: vec![true; n],
+            last_status: vec![Status::Normal; n],
+            callout_timer: vec![0.0; n],
+            callout_text: (0..n).map(|_| String::new()).collect(),
+            callout_color: vec![COOL_WHITE; n],
         }
     }
 }
 
+impl Default for ReplicaFxState {
+    fn default() -> Self {
+        Self::new(DEFAULT_REPLICA_COUNT)
+    }
+}
+
 #[derive(Resource, Default)]
 pub(crate) struct AppFxState {
     pub(crate) pulse: [f32; 4],
@@ -131,9 +153,24 @@ impl EventLog {
 #[derive(Resource)]
 pub(crate) struct GameConsole {
     pub(crate) raw_lines: RawLineBuffer,
+    pub(crate) raw_generation: RawLineGeneration,
     pub(crate) open: bool,
     pub(crate) slide: f32,
-    pub(crate) last_line_count: usize,
+    pub(crate) last_seen_generation: u64,
+}
+
+pub(crate) const PACK_OPTIONS: [u8; 3] = [3, 5, 7];
+
+#[derive(Resource)]
+pub(crate) struct SelectionState {
+    pub(crate) index: usize,
+}
+
+#[allow(clippy::derivable_impls)]
+impl Default for SelectionState {
+    fn default() -> Self {
+        Self { index: 0 }
+    }
 }
 
 #[derive(Resource, Default)]
diff --git a/core/simulator_ui/src/setup.rs b/core/simulator_ui/src/setup.rs
index 0c11a076b..fbcf96290 100644
--- a/core/simulator_ui/src/setup.rs
+++ b/core/simulator_ui/src/setup.rs
@@ -20,6 +20,7 @@ use std::f32::consts::PI;
 use bevy::prelude::*;
 use bevy_prototype_lyon::prelude::*;
 
+use crate::bridge::UiSimulator;
 use crate::components::*;
 use crate::helpers::*;
 use crate::resources::*;
@@ -29,13 +30,7 @@ pub(crate) fn setup_camera(mut commands: Commands) {
     commands.spawn(Camera2d);
 }
 
-pub(crate) fn setup_world(
-    mut commands: Commands,
-    positions: Res<ReplicaPositions>,
-    asset_server: Res<AssetServer>,
-) {
-    let mascot_handle: Handle<Image> = asset_server.load("iggy.png");
-
+pub(crate) fn setup_background(mut commands: Commands) {
     for offset in [-420.0, -280.0, -140.0, 0.0, 140.0, 280.0, 420.0] {
         let rising = shapes::Line(
             Vec2::new(-700.0, -520.0 + offset),
@@ -70,6 +65,344 @@ pub(crate) fn setup_world(
         ));
     }
 
+    for particle_idx in 0..AMBIENT_PARTICLE_COUNT {
+        let phase = particle_idx as f32 / AMBIENT_PARTICLE_COUNT as f32;
+        let x_pos = (phase * 17.3 + 0.5).fract() * 1600.0 - 800.0;
+        let y_pos = (phase * 23.7 + 0.2).fract() * 1100.0 - 550.0;
+        let size = 0.8 + (phase * 7.1).fract() * 1.8;
+        let alpha = 0.04 + (phase * 13.3).fract() * 0.10;
+        let color = match particle_idx % 5 {
+            0 => NEON_CYAN,
+            1 => IGGY_ORANGE,
+            _ => COOL_WHITE,
+        };
+
+        commands.spawn((
+            AmbientParticle {
+                velocity: Vec2::new(
+                    ((phase * 31.1).fract() - 0.5) * 12.0,
+                    8.0 + (phase * 11.7).fract() * 18.0,
+                ),
+                drift_phase: phase * PI * 2.0,
+                drift_speed: 0.4 + (phase * 5.3).fract() * 0.8,
+                base_alpha: alpha,
+            },
+            build_fill(&circle_shape(size), color.with_alpha(alpha)),
+            Transform::from_xyz(x_pos, y_pos, -0.08),
+        ));
+    }
+
+    commands.spawn((
+        ScreenFlashOverlay,
+        Sprite::from_color(NEON_CYAN.with_alpha(0.0), Vec2::new(1600.0, 
1100.0)),
+        Transform::from_xyz(0.0, 0.0, 90.0),
+    ));
+}
+
+pub(crate) fn setup_selection_screen(mut commands: Commands, asset_server: 
Res<AssetServer>) {
+    // sygnet is the properly cropped mascot (6910x5211, ~1.33:1 ratio)
+    let sygnet: Handle<Image> = 
asset_server.load("logo/4x/[email protected]");
+    let full_logo: Handle<Image> = 
asset_server.load("logo/4x/[email protected]");
+
+    commands
+        .spawn((
+            SelectionScreen,
+            Node {
+                width: Val::Percent(100.0),
+                height: Val::Percent(100.0),
+                justify_content: JustifyContent::Center,
+                align_items: AlignItems::Center,
+                flex_direction: FlexDirection::Column,
+                row_gap: Val::Px(10.0),
+                ..default()
+            },
+        ))
+        .with_children(|root| {
+            root.spawn((
+                ImageNode::new(full_logo),
+                Node {
+                    width: Val::Px(300.0),
+                    height: Val::Px(100.0),
+                    margin: UiRect::bottom(Val::Px(8.0)),
+                    ..default()
+                },
+            ));
+
+            root.spawn((
+                Text::new("VSR SIMULATOR"),
+                TextFont {
+                    font_size: 46.0,
+                    ..default()
+                },
+                TextColor(IGGY_ORANGE),
+            ));
+
+            root.spawn((
+                Text::new("Viewstamped Replication visualized with racing 
greyhounds"),
+                TextFont {
+                    font_size: 13.0,
+                    ..default()
+                },
+                TextColor(DIM_GRAY),
+            ));
+
+            root.spawn((
+                SelectionTitle,
+                Text::new("CHOOSE YOUR PACK"),
+                TextFont {
+                    font_size: 22.0,
+                    ..default()
+                },
+                TextColor(NEON_CYAN),
+                Node {
+                    margin: UiRect::top(Val::Px(20.0)),
+                    ..default()
+                },
+            ));
+
+            root.spawn(Node {
+                flex_direction: FlexDirection::Row,
+                column_gap: Val::Px(28.0),
+                margin: UiRect::axes(Val::Px(0.0), Val::Px(10.0)),
+                ..default()
+            })
+            .with_children(|row| {
+                let options: [(u8, &str, &str); 3] = [
+                    (3, "TIGHT PACK", "tolerates 1 fault"),
+                    (5, "BALANCED", "tolerates 2 faults"),
+                    (7, "FULL PACK", "tolerates 3 faults"),
+                ];
+
+                for (idx, (count, title, detail)) in 
options.iter().enumerate() {
+                    row.spawn((
+                        SelectionCard { index: idx },
+                        Node {
+                            width: Val::Px(220.0),
+                            flex_direction: FlexDirection::Column,
+                            align_items: AlignItems::Center,
+                            justify_content: JustifyContent::Center,
+                            padding: UiRect::new(
+                                Val::Px(20.0),
+                                Val::Px(20.0),
+                                Val::Px(22.0),
+                                Val::Px(18.0),
+                            ),
+                            border: UiRect::all(Val::Px(2.0)),
+                            row_gap: Val::Px(6.0),
+                            ..default()
+                        },
+                        BackgroundColor(Color::srgba_u8(0x0c, 0x14, 0x24, 
0xcc)),
+                        BorderColor::all(DIM_GRAY.with_alpha(0.15)),
+                    ))
+                    .with_children(|card| {
+                        card.spawn(Node {
+                            flex_direction: FlexDirection::Row,
+                            column_gap: Val::Px(2.0),
+                            margin: UiRect::bottom(Val::Px(4.0)),
+                            justify_content: JustifyContent::Center,
+                            ..default()
+                        })
+                        .with_children(|dogs_row| {
+                            for _ in 0..*count {
+                                dogs_row.spawn((
+                                    ImageNode::new(sygnet.clone()),
+                                    Node {
+                                        width: Val::Px(28.0),
+                                        height: Val::Px(21.0),
+                                        ..default()
+                                    },
+                                ));
+                            }
+                        });
+
+                        card.spawn((
+                            SelectionNumber { index: idx },
+                            Text::new(format!("{count}")),
+                            TextFont {
+                                font_size: 56.0,
+                                ..default()
+                            },
+                            TextColor(DIM_GRAY.with_alpha(0.4)),
+                        ));
+
+                        card.spawn((
+                            SelectionLabel { index: idx },
+                            Text::new(*title),
+                            TextFont {
+                                font_size: 15.0,
+                                ..default()
+                            },
+                            TextColor(DIM_GRAY.with_alpha(0.35)),
+                        ));
+
+                        card.spawn((
+                            Text::new(*detail),
+                            TextFont {
+                                font_size: 11.0,
+                                ..default()
+                            },
+                            TextColor(DIM_GRAY.with_alpha(0.3)),
+                        ));
+                    });
+                }
+            });
+
+            root.spawn(Node {
+                flex_direction: FlexDirection::Row,
+                column_gap: Val::Px(20.0),
+                align_items: AlignItems::Center,
+                margin: UiRect::top(Val::Px(12.0)),
+                ..default()
+            })
+            .with_children(|hint| {
+                for (key, action, color) in [
+                    ("< >", "select", NEON_CYAN),
+                    ("SPACE", "unleash the pack!", IGGY_ORANGE),
+                ] {
+                    hint.spawn(Node {
+                        flex_direction: FlexDirection::Row,
+                        align_items: AlignItems::Center,
+                        column_gap: Val::Px(6.0),
+                        ..default()
+                    })
+                    .with_children(|pair| {
+                        pair.spawn((
+                            Node {
+                                padding: UiRect::axes(Val::Px(12.0), 
Val::Px(6.0)),
+                                border: UiRect::all(Val::Px(1.0)),
+                                ..default()
+                            },
+                            BackgroundColor(Color::srgba_u8(0x0c, 0x14, 0x24, 
0xdd)),
+                            BorderColor::all(color.with_alpha(0.5)),
+                        ))
+                        .with_children(|badge| {
+                            badge.spawn((
+                                Text::new(key),
+                                TextFont {
+                                    font_size: 15.0,
+                                    ..default()
+                                },
+                                TextColor(color),
+                            ));
+                        });
+                        pair.spawn((
+                            Text::new(action),
+                            TextFont {
+                                font_size: 13.0,
+                                ..default()
+                            },
+                            TextColor(COOL_WHITE.with_alpha(0.7)),
+                        ));
+                    });
+                }
+            });
+        });
+}
+
+#[allow(clippy::type_complexity)]
+pub(crate) fn update_selection_screen(
+    time: Res<Time>,
+    state: Res<SelectionState>,
+    mut cards: Query<
+        (&SelectionCard, &mut BorderColor, &mut BackgroundColor),
+        Without<SelectionNumber>,
+    >,
+    mut numbers: Query<
+        (&SelectionNumber, &mut TextColor),
+        (Without<SelectionCard>, Without<SelectionLabel>),
+    >,
+    mut labels: Query<
+        (&SelectionLabel, &mut TextColor),
+        (Without<SelectionCard>, Without<SelectionNumber>),
+    >,
+    mut title: Query<
+        &mut TextColor,
+        (
+            With<SelectionTitle>,
+            Without<SelectionCard>,
+            Without<SelectionNumber>,
+            Without<SelectionLabel>,
+        ),
+    >,
+) {
+    let elapsed = time.elapsed_secs();
+    let pulse = (elapsed * 3.5).sin() * 0.5 + 0.5;
+    let fast_pulse = (elapsed * 5.0).sin() * 0.5 + 0.5;
+    let slow_pulse = (elapsed * 1.8).sin() * 0.5 + 0.5;
+
+    for mut color in title.iter_mut() {
+        *color = TextColor(NEON_CYAN.with_alpha(0.7 + slow_pulse * 0.3));
+    }
+
+    for (card, mut border, mut bg) in cards.iter_mut() {
+        if card.index == state.index {
+            let glow = 0.6 + fast_pulse * 0.4;
+            *border = BorderColor::all(IGGY_ORANGE.with_alpha(glow));
+            *bg = BackgroundColor(IGGY_ORANGE.with_alpha(0.08 + pulse * 0.06));
+        } else {
+            *border = BorderColor::all(Color::srgba_u8(0x22, 0x2a, 0x3a, 
0x44));
+            *bg = BackgroundColor(Color::srgba_u8(0x08, 0x0e, 0x1a, 0x88));
+        }
+    }
+
+    for (num, mut color) in numbers.iter_mut() {
+        if num.index == state.index {
+            let bright = 0.9 + fast_pulse * 0.1;
+            *color = TextColor(IGGY_ORANGE.with_alpha(bright));
+        } else {
+            *color = TextColor(DIM_GRAY.with_alpha(0.22));
+        }
+    }
+
+    for (label, mut color) in labels.iter_mut() {
+        if label.index == state.index {
+            *color = TextColor(NEON_CYAN.with_alpha(0.8 + pulse * 0.2));
+        } else {
+            *color = TextColor(DIM_GRAY.with_alpha(0.18));
+        }
+    }
+}
+
+pub(crate) fn despawn_selection_screen(
+    mut commands: Commands,
+    query: Query<Entity, With<SelectionScreen>>,
+) {
+    for entity in query.iter() {
+        commands.entity(entity).despawn();
+    }
+}
+
+pub(crate) fn reinit_simulation(
+    mut sim: NonSendMut<SimulationState>,
+    config: Res<ReplicaConfig>,
+    buffers: Res<SharedBuffers>,
+    mut positions: ResMut<ReplicaPositions>,
+    mut fx: ResMut<ReplicaFxState>,
+) {
+    *sim = SimulationState {
+        simulator: UiSimulator::new(config.count, 42, 
buffers.event_buffer.clone()),
+        playing: false,
+        speed: 1.0,
+        tick_accumulator: 0.0,
+        total_commits: 0,
+        ops_per_second: 0.0,
+        ops_window_start: 0.0,
+        ops_window_count: 0,
+        frame_count: 0,
+    };
+    *positions = ReplicaPositions::new(config.count);
+    *fx = ReplicaFxState::new(config.count);
+}
+
+pub(crate) fn setup_simulation_world(
+    mut commands: Commands,
+    config: Res<ReplicaConfig>,
+    positions: Res<ReplicaPositions>,
+    asset_server: Res<AssetServer>,
+) {
+    let replica_count = config.count;
+    let mascot_handle: Handle<Image> = asset_server.load("iggy.png");
+
     for scan_idx in 0..SCAN_LINE_COUNT {
         let y_pos = -500.0 + (scan_idx as f32) * (1000.0 / SCAN_LINE_COUNT as 
f32);
         let line = shapes::Line(Vec2::new(-700.0, 0.0), Vec2::new(700.0, 0.0));
@@ -82,6 +415,7 @@ pub(crate) fn setup_world(
         ));
     }
 
+    // Track rings
     commands.spawn((
         TrackRing,
         build_stroke(
@@ -108,40 +442,8 @@ pub(crate) fn setup_world(
         Transform::from_xyz(0.0, WORLD_CENTER_Y, -0.01),
     ));
 
-    for particle_idx in 0..AMBIENT_PARTICLE_COUNT {
-        let phase = particle_idx as f32 / AMBIENT_PARTICLE_COUNT as f32;
-        let x_pos = (phase * 17.3 + 0.5).fract() * 1600.0 - 800.0;
-        let y_pos = (phase * 23.7 + 0.2).fract() * 1100.0 - 550.0;
-        let size = 0.8 + (phase * 7.1).fract() * 1.8;
-        let alpha = 0.04 + (phase * 13.3).fract() * 0.10;
-        let color = match particle_idx % 5 {
-            0 => NEON_CYAN,
-            1 => IGGY_ORANGE,
-            _ => COOL_WHITE,
-        };
-
-        commands.spawn((
-            AmbientParticle {
-                velocity: Vec2::new(
-                    ((phase * 31.1).fract() - 0.5) * 12.0,
-                    8.0 + (phase * 11.7).fract() * 18.0,
-                ),
-                drift_phase: phase * PI * 2.0,
-                drift_speed: 0.4 + (phase * 5.3).fract() * 0.8,
-                base_alpha: alpha,
-            },
-            build_fill(&circle_shape(size), color.with_alpha(alpha)),
-            Transform::from_xyz(x_pos, y_pos, -0.08),
-        ));
-    }
-
-    commands.spawn((
-        ScreenFlashOverlay,
-        Sprite::from_color(NEON_CYAN.with_alpha(0.0), Vec2::new(1600.0, 
1100.0)),
-        Transform::from_xyz(0.0, 0.0, 90.0),
-    ));
-
-    for replica_id in 0..REPLICA_COUNT {
+    // Replicas
+    for replica_id in 0..replica_count {
         let position = positions.0[replica_id as usize];
         let is_primary = replica_id == 0;
         let accent = if is_primary { IGGY_ORANGE } else { NEON_CYAN };
@@ -235,8 +537,9 @@ pub(crate) fn setup_world(
         ));
     }
 
-    for from_id in 0..REPLICA_COUNT {
-        for to_id in (from_id + 1)..REPLICA_COUNT {
+    // Network links between replicas
+    for from_id in 0..replica_count {
+        for to_id in (from_id + 1)..replica_count {
             let from_pos = positions.0[from_id as usize];
             let to_pos = positions.0[to_id as usize];
             let bend = if (from_id + to_id) % 2 == 0 {
@@ -254,6 +557,7 @@ pub(crate) fn setup_world(
         }
     }
 
+    // App cards (producers / consumers)
     let apps = [
         (
             0usize,
@@ -269,7 +573,7 @@ pub(crate) fn setup_world(
             "BALL FETCHER",
             AppKind::Consumer,
             Vec2::new(560.0, 330.0),
-            1u8,
+            1u8.min(replica_count - 1),
             NEON_CYAN,
             false,
         ),
@@ -278,7 +582,7 @@ pub(crate) fn setup_world(
             "TREAT DISPENSER",
             AppKind::Producer,
             Vec2::new(-560.0, 70.0),
-            2u8,
+            2u8.min(replica_count - 1),
             NEON_YELLOW,
             true,
         ),
@@ -338,12 +642,18 @@ pub(crate) fn setup_world(
     }
 }
 
-pub(crate) fn setup_hud(mut commands: Commands, asset_server: 
Res<AssetServer>) {
+pub(crate) fn setup_hud(
+    mut commands: Commands,
+    asset_server: Res<AssetServer>,
+    config: Res<ReplicaConfig>,
+) {
     let sygnet_handle: Handle<Image> =
         asset_server.load("logo/4x/[email protected]");
     let wordmark_handle: Handle<Image> =
         asset_server.load("logo/4x/[email protected]");
 
+    let greyhound_label = format!("{} GREYHOUNDS", config.count);
+
     commands
         .spawn(Node {
             width: Val::Percent(100.0),
@@ -605,9 +915,10 @@ pub(crate) fn setup_hud(mut commands: Commands, 
asset_server: Res<AssetServer>)
                     ..default()
                 })
                 .with_children(|chips| {
-                    for (label, color) in
-                        [("3 GREYHOUNDS", IGGY_ORANGE), ("FAULT READY", 
HUD_ALERT)]
-                    {
+                    for (label, color) in [
+                        (greyhound_label.as_str(), IGGY_ORANGE),
+                        ("FAULT READY", HUD_ALERT),
+                    ] {
                         chips
                             .spawn((
                                 Node {
diff --git a/core/simulator_ui/src/simulation.rs 
b/core/simulator_ui/src/simulation.rs
index 021a578c3..d8f102d5c 100644
--- a/core/simulator_ui/src/simulation.rs
+++ b/core/simulator_ui/src/simulation.rs
@@ -32,6 +32,7 @@ pub(crate) fn tick_simulation(
     mut commands: Commands,
     time: Res<Time>,
     mut sim: NonSendMut<SimulationState>,
+    config: Res<ReplicaConfig>,
     positions: Res<ReplicaPositions>,
     mut fx: FxParams,
     mut event_log: ResMut<EventLog>,
@@ -47,6 +48,7 @@ pub(crate) fn tick_simulation(
         return;
     }
 
+    let replica_count = config.count;
     let delta = time.delta_secs();
     sim.tick_accumulator += delta * sim.speed * 60.0;
 
@@ -58,15 +60,20 @@ pub(crate) fn tick_simulation(
         let events = sim.simulator.step();
 
         for event in &events {
-            if let Some(entry) = narrate_event(event, tick) {
+            if let Some(entry) = narrate_event(event, tick, replica_count) {
                 event_log.push(entry);
             }
             match event.sim_event.as_str() {
                 "ControlMessageScheduled" => {
-                    spawn_control_message_particles(&mut commands, event, 
&positions);
+                    spawn_control_message_particles(
+                        &mut commands,
+                        event,
+                        &positions,
+                        replica_count,
+                    );
                 }
                 "PrepareQueued" => {
-                    spawn_prepare_particles(&mut commands, event, &positions);
+                    spawn_prepare_particles(&mut commands, event, &positions, 
replica_count);
                 }
                 "OperationCommitted" => {
                     handle_commit_event(
@@ -88,6 +95,7 @@ pub(crate) fn tick_simulation(
                         &mut fx.replica_fx,
                         &mut fx.app_fx,
                         &mut fx.screen_flash,
+                        replica_count,
                     );
                 }
                 "ClientRequestReceived" => {
@@ -127,13 +135,14 @@ pub(crate) fn spawn_control_message_particles(
     commands: &mut Commands,
     event: &CapturedSimEvent,
     positions: &ReplicaPositions,
+    replica_count: u8,
 ) {
     let action = event.action.as_deref().unwrap_or("");
     let msg_type = 
MessageType::from_action(action).unwrap_or(MessageType::Prepare);
     let from_idx = event.replica_id.unwrap_or(0) as usize;
     let to_idx = event.target_replica.unwrap_or(0) as usize;
 
-    if from_idx >= REPLICA_COUNT as usize || to_idx >= REPLICA_COUNT as usize {
+    if from_idx >= replica_count as usize || to_idx >= replica_count as usize {
         return;
     }
 
@@ -179,16 +188,17 @@ pub(crate) fn spawn_prepare_particles(
     commands: &mut Commands,
     event: &CapturedSimEvent,
     positions: &ReplicaPositions,
+    replica_count: u8,
 ) {
     let from_idx = event.replica_id.unwrap_or(0) as usize;
-    if from_idx >= REPLICA_COUNT as usize {
+    if from_idx >= replica_count as usize {
         return;
     }
 
     let from_pos = positions.0[from_idx];
     let msg_color = MessageType::Prepare.color();
 
-    for to_idx in 0..REPLICA_COUNT as usize {
+    for to_idx in 0..replica_count as usize {
         if to_idx == from_idx {
             continue;
         }
@@ -344,6 +354,7 @@ pub(crate) fn handle_commit_event(
     }
 }
 
+#[allow(clippy::too_many_arguments)]
 pub(crate) fn handle_view_change_event(
     commands: &mut Commands,
     event: &CapturedSimEvent,
@@ -352,6 +363,7 @@ pub(crate) fn handle_view_change_event(
     replica_fx: &mut ResMut<ReplicaFxState>,
     app_fx: &mut ResMut<AppFxState>,
     screen_flash: &mut ResMut<ScreenFlash>,
+    replica_count: u8,
 ) {
     let replica_id = event.replica_id.unwrap_or(0) as u8;
     trigger_replica_callout(replica_fx, replica_id, "PACK SHUFFLE!", 
NEON_MAGENTA, 1.15);
@@ -359,7 +371,7 @@ pub(crate) fn handle_view_change_event(
     screen_flash.timer = SCREEN_FLASH_DURATION;
     screen_flash.color = NEON_MAGENTA;
     screen_flash.intensity = 0.12;
-    for rid in 0..REPLICA_COUNT {
+    for rid in 0..replica_count {
         commands.spawn(GlowFlash {
             id: rid,
             timer: 0.3,
@@ -386,8 +398,8 @@ pub(crate) fn handle_view_change_event(
 
     let bolt_count = 3 + (sim.frame_count % 2) as usize;
     for bolt_idx in 0..bolt_count {
-        let from_idx = bolt_idx % REPLICA_COUNT as usize;
-        let to_idx = (bolt_idx + 1) % REPLICA_COUNT as usize;
+        let from_idx = bolt_idx % replica_count as usize;
+        let to_idx = (bolt_idx + 1) % replica_count as usize;
         let from_pos = positions.0[from_idx];
         let to_pos = positions.0[to_idx];
         let segment_count = 3 + (bolt_idx % 2);
diff --git a/core/simulator_ui/src/theme.rs b/core/simulator_ui/src/theme.rs
index 0b7bab246..a2de83a5f 100644
--- a/core/simulator_ui/src/theme.rs
+++ b/core/simulator_ui/src/theme.rs
@@ -29,7 +29,7 @@ pub(crate) const GRID_LINE: Color = Color::srgb_u8(0x11, 
0x1d, 0x35);
 pub(crate) const HUD_EDGE: Color = Color::srgb_u8(0x3d, 0x44, 0x50);
 pub(crate) const HUD_ALERT: Color = Color::srgb_u8(0xff, 0x5f, 0x57);
 
-pub(crate) const REPLICA_COUNT: u8 = 3;
+pub(crate) const DEFAULT_REPLICA_COUNT: u8 = 3;
 pub(crate) const CIRCLE_RADIUS: f32 = 300.0;
 pub(crate) const WORLD_CENTER_Y: f32 = 90.0;
 pub(crate) const IGGY_SPRITE_SIZE: Vec2 = Vec2::new(210.0, 176.0);
diff --git a/core/simulator_ui/src/tracing_layer.rs 
b/core/simulator_ui/src/tracing_layer.rs
index b6c421909..d0c7e6a9d 100644
--- a/core/simulator_ui/src/tracing_layer.rs
+++ b/core/simulator_ui/src/tracing_layer.rs
@@ -17,6 +17,7 @@
 
 use std::collections::VecDeque;
 use std::fmt::Write;
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::{Arc, Mutex};
 
 use crate::util::recover_lock;
@@ -54,6 +55,7 @@ pub struct CapturedSimEvent {
 
 pub type EventBuffer = Arc<Mutex<Vec<CapturedSimEvent>>>;
 pub type RawLineBuffer = Arc<Mutex<VecDeque<String>>>;
+pub type RawLineGeneration = Arc<AtomicU64>;
 
 struct SimEventVisitor(CapturedSimEvent);
 
@@ -124,11 +126,20 @@ impl Visit for SimEventVisitor {
 pub struct SimEventLayer {
     buffer: EventBuffer,
     raw_lines: RawLineBuffer,
+    raw_generation: RawLineGeneration,
 }
 
 impl SimEventLayer {
-    pub fn new(buffer: EventBuffer, raw_lines: RawLineBuffer) -> Self {
-        Self { buffer, raw_lines }
+    pub fn new(
+        buffer: EventBuffer,
+        raw_lines: RawLineBuffer,
+        raw_generation: RawLineGeneration,
+    ) -> Self {
+        Self {
+            buffer,
+            raw_lines,
+            raw_generation,
+        }
     }
 }
 
@@ -154,6 +165,8 @@ impl<S: Subscriber> Layer<S> for SimEventLayer {
         raw.push_back(line);
         drop(raw);
 
+        self.raw_generation.fetch_add(1, Ordering::Relaxed);
+
         recover_lock(&self.buffer).push(visitor.0);
     }
 }
diff --git a/core/simulator_ui/src/visuals.rs b/core/simulator_ui/src/visuals.rs
index 8a18e8918..87129fdfe 100644
--- a/core/simulator_ui/src/visuals.rs
+++ b/core/simulator_ui/src/visuals.rs
@@ -27,10 +27,12 @@ use crate::resources::*;
 use crate::theme::*;
 use crate::types::{Role, Status};
 
+#[allow(clippy::type_complexity)]
 pub(crate) fn update_replica_visuals(
-    (time, sim, positions, mut replica_fx, mut commands): (
+    (time, sim, config, positions, mut replica_fx, mut commands): (
         Res<Time>,
         NonSend<SimulationState>,
+        Res<ReplicaConfig>,
         Res<ReplicaPositions>,
         ResMut<ReplicaFxState>,
         Commands,
@@ -53,15 +55,15 @@ pub(crate) fn update_replica_visuals(
     let elapsed = time.elapsed_secs();
     let delta = time.delta_secs();
 
-    for idx in 0..REPLICA_COUNT as usize {
+    let replica_count = config.count;
+    for idx in 0..replica_count as usize {
         replica_fx.kill[idx] = (replica_fx.kill[idx] - delta * 1.4).max(0.0);
         replica_fx.revive[idx] = (replica_fx.revive[idx] - delta * 
1.1).max(0.0);
         replica_fx.healthy[idx] = (replica_fx.healthy[idx] - delta * 
0.9).max(0.0);
         replica_fx.callout_timer[idx] = (replica_fx.callout_timer[idx] - 
delta).max(0.0);
     }
 
-    let mut flash_overrides: [Option<Color>; REPLICA_COUNT as usize] =
-        [None; REPLICA_COUNT as usize];
+    let mut flash_overrides: Vec<Option<Color>> = vec![None; replica_count as 
usize];
     for (entity, mut flash) in flash_query.iter_mut() {
         flash.timer -= delta;
         if flash.timer <= 0.0 {
@@ -70,7 +72,7 @@ pub(crate) fn update_replica_visuals(
         }
 
         let idx = flash.id as usize;
-        if idx >= REPLICA_COUNT as usize {
+        if idx >= replica_count as usize {
             continue;
         }
         flash_overrides[idx] = Some(flash.flash_color);


Reply via email to