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 e82f3c95b Add vocabulary
e82f3c95b is described below

commit e82f3c95b2e7603dc7042b4931a92699fd7ce541
Author: spetz <[email protected]>
AuthorDate: Tue Apr 14 16:42:52 2026 +0200

    Add vocabulary
---
 core/simulator_ui/src/components.rs |  10 +
 core/simulator_ui/src/helpers.rs    | 481 ++++++++++++++++++++++++------------
 core/simulator_ui/src/input.rs      |  62 +++--
 core/simulator_ui/src/main.rs       |   3 +
 core/simulator_ui/src/setup.rs      | 157 +++++++++---
 core/simulator_ui/src/simulation.rs |  42 +++-
 core/simulator_ui/src/visuals.rs    |  59 ++++-
 core/simulator_ui/src/vocabulary.rs | 392 +++++++++++++++++++++++++++++
 8 files changed, 978 insertions(+), 228 deletions(-)

diff --git a/core/simulator_ui/src/components.rs 
b/core/simulator_ui/src/components.rs
index 4b3669f6a..8225067e6 100644
--- a/core/simulator_ui/src/components.rs
+++ b/core/simulator_ui/src/components.rs
@@ -247,3 +247,13 @@ pub(crate) struct SelectionLabel {
 
 #[derive(Component)]
 pub(crate) struct SelectionTitle;
+
+#[derive(Component)]
+pub(crate) struct VocabCard {
+    pub(crate) is_dog: bool,
+}
+
+#[derive(Component)]
+pub(crate) struct VocabLabel {
+    pub(crate) is_dog: bool,
+}
diff --git a/core/simulator_ui/src/helpers.rs b/core/simulator_ui/src/helpers.rs
index 76e13f8e8..446d4207d 100644
--- a/core/simulator_ui/src/helpers.rs
+++ b/core/simulator_ui/src/helpers.rs
@@ -23,6 +23,7 @@ use crate::resources::*;
 use crate::theme::*;
 use crate::tracing_layer::CapturedSimEvent;
 use crate::types::{MessageType, Role, Status};
+use crate::vocabulary::VocabMode;
 
 pub(crate) fn circle_shape(radius: f32) -> shapes::Circle {
     shapes::Circle {
@@ -103,30 +104,6 @@ pub(crate) fn status_color(status: Status, alive: bool) -> 
Color {
     }
 }
 
-pub(crate) fn role_label(role: Role, status: Status) -> &'static str {
-    match (role, status) {
-        (Role::Primary, Status::Normal) => "LEAD DOG / running",
-        (Role::Primary, Status::ViewChange) => "LEAD DOG / howling",
-        (Role::Primary, Status::Recovering) => "LEAD DOG / limping",
-        (Role::Backup, Status::Normal) => "PACK DOG / running",
-        (Role::Backup, Status::ViewChange) => "PACK DOG / howling",
-        (Role::Backup, Status::Recovering) => "PACK DOG / limping",
-    }
-}
-
-pub(crate) fn dog_name(id: u8) -> &'static str {
-    match id {
-        0 => "Iggy",
-        1 => "Zippy",
-        2 => "Dash",
-        3 => "Bolt",
-        4 => "Flash",
-        5 => "Storm",
-        6 => "Blaze",
-        _ => "Pup",
-    }
-}
-
 pub(crate) fn trigger_replica_callout(
     replica_fx: &mut ReplicaFxState,
     id: u8,
@@ -232,19 +209,33 @@ pub(crate) fn narrate_event(
     event: &CapturedSimEvent,
     tick: u64,
     replica_count: u8,
+    vocab: VocabMode,
 ) -> Option<EventLogEntry> {
     match event.sim_event.as_str() {
         "ClientRequestReceived" => {
             let replica_id = event.replica_id.unwrap_or(0) as u8;
-            let name = dog_name(replica_id);
+            let name = vocab.node_name(replica_id);
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => (
+                    format!("Ball thrown to {name}!"),
+                    format!(
+                        "A client tossed a new request to {name} 
(R{replica_id}). \
+                         As lead dog, {name} will replicate it to the pack 
before confirming."
+                    ),
+                ),
+                VocabMode::Technical => (
+                    format!("Client request -> {name}"),
+                    format!(
+                        "Primary {name} (R{replica_id}) received client 
request. \
+                         Will broadcast Prepare to backups."
+                    ),
+                ),
+            };
             Some(EventLogEntry {
                 tick,
                 icon: "[REQ]",
-                headline: format!("Ball thrown to {name}!"),
-                detail: format!(
-                    "A client tossed a new request to {name} (R{replica_id}). \
-                     As lead dog, {name} will replicate it to the pack before 
confirming."
-                ),
+                headline,
+                detail,
                 color: IGGY_ORANGE,
             })
         }
@@ -252,15 +243,28 @@ pub(crate) fn narrate_event(
             let replica_id = event.replica_id.unwrap_or(0) as u8;
             let op = event.op.unwrap_or(0);
             let pipeline_depth = event.pipeline_depth.unwrap_or(0);
-            let name = dog_name(replica_id);
+            let name = vocab.node_name(replica_id);
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => (
+                    format!("{name} queued op #{op}"),
+                    format!(
+                        "{name} added operation #{op} to the pipeline 
({pipeline_depth}/8 slots used). \
+                         Now broadcasting Prepare to the pack -- every dog 
must log this before it counts."
+                    ),
+                ),
+                VocabMode::Technical => (
+                    format!("{name} queued op #{op}"),
+                    format!(
+                        "Operation #{op} added to pipeline ({pipeline_depth}/8 
used). \
+                         Broadcasting Prepare to all backups."
+                    ),
+                ),
+            };
             Some(EventLogEntry {
                 tick,
                 icon: "[QUE]",
-                headline: format!("{name} queued op #{op}"),
-                detail: format!(
-                    "{name} added operation #{op} to the pipeline 
({pipeline_depth}/8 slots used). \
-                     Now broadcasting Prepare to the pack -- every dog must 
log this before it counts."
-                ),
+                headline,
+                detail,
                 color: Color::srgb_u8(95, 135, 253),
             })
         }
@@ -271,26 +275,50 @@ pub(crate) fn narrate_event(
             let ack_count = event.ack_count.unwrap_or(0);
             let quorum = event.quorum.unwrap_or(0);
             let quorum_reached = event.quorum_reached.unwrap_or(false);
-            let lead = dog_name(replica_id);
-            let acker = dog_name(ack_from);
-            let status_detail = if quorum_reached {
-                format!("That's {ack_count}/{quorum} -- quorum reached! {lead} 
can now commit.")
-            } else {
-                format!(
-                    "That's {ack_count}/{quorum} acks. Need {} more for 
quorum.",
-                    quorum.saturating_sub(ack_count)
-                )
+            let lead = vocab.node_name(replica_id);
+            let acker = vocab.node_name(ack_from);
+            let quorum_suffix = if quorum_reached { " -- QUORUM!" } else { "" 
};
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => {
+                    let status_detail = if quorum_reached {
+                        format!(
+                            "That's {ack_count}/{quorum} -- quorum reached! 
{lead} can now commit."
+                        )
+                    } else {
+                        format!(
+                            "That's {ack_count}/{quorum} acks. Need {} more 
for quorum.",
+                            quorum.saturating_sub(ack_count)
+                        )
+                    };
+                    (
+                        format!("{acker} acked op #{op}{quorum_suffix}"),
+                        format!(
+                            "{acker} (R{ack_from}) confirmed it logged 
operation #{op}. {status_detail}"
+                        ),
+                    )
+                }
+                VocabMode::Technical => {
+                    let status_detail = if quorum_reached {
+                        format!("{ack_count}/{quorum} acks -- quorum reached. 
{lead} will commit.")
+                    } else {
+                        format!(
+                            "{ack_count}/{quorum} acks. Need {} more for 
quorum.",
+                            quorum.saturating_sub(ack_count)
+                        )
+                    };
+                    (
+                        format!("{acker} acked op #{op}{quorum_suffix}"),
+                        format!(
+                            "{acker} (R{ack_from}) acknowledged operation 
#{op}. {status_detail}"
+                        ),
+                    )
+                }
             };
             Some(EventLogEntry {
                 tick,
                 icon: if quorum_reached { "[OK!]" } else { "[ACK]" },
-                headline: format!(
-                    "{acker} acked op #{op}{}",
-                    if quorum_reached { " -- QUORUM!" } else { "" }
-                ),
-                detail: format!(
-                    "{acker} (R{ack_from}) confirmed it logged operation 
#{op}. {status_detail}"
-                ),
+                headline,
+                detail,
                 color: if quorum_reached {
                     IGGY_ORANGE
                 } else {
@@ -301,30 +329,53 @@ pub(crate) fn narrate_event(
         "OperationCommitted" => {
             let replica_id = event.replica_id.unwrap_or(0) as u8;
             let op = event.op.unwrap_or(0);
-            let name = dog_name(replica_id);
+            let name = vocab.node_name(replica_id);
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => (
+                    format!("{name} committed op #{op}!"),
+                    format!(
+                        "Operation #{op} is officially committed on {name} 
(R{replica_id}). \
+                         The pack agreed -- this data is now durable and safe. 
Good boy!"
+                    ),
+                ),
+                VocabMode::Technical => (
+                    format!("{name} committed op #{op}"),
+                    format!(
+                        "Op #{op} committed on {name} (R{replica_id}). \
+                         Quorum achieved, data durable."
+                    ),
+                ),
+            };
             Some(EventLogEntry {
                 tick,
                 icon: "[WIN]",
-                headline: format!("{name} committed op #{op}!"),
-                detail: format!(
-                    "Operation #{op} is officially committed on {name} 
(R{replica_id}). \
-                     The pack agreed -- this data is now durable and safe. 
Good boy!"
-                ),
+                headline,
+                detail,
                 color: IGGY_ORANGE,
             })
         }
         "ClientReplyEmitted" => {
             let replica_id = event.replica_id.unwrap_or(0) as u8;
             let op = event.op.unwrap_or(0);
-            let name = dog_name(replica_id);
+            let name = vocab.node_name(replica_id);
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => (
+                    format!("{name} returned the ball!"),
+                    format!(
+                        "{name} sent a reply to the client. The request for op 
#{op} \
+                         was committed and acknowledged. Fetch complete!"
+                    ),
+                ),
+                VocabMode::Technical => (
+                    format!("{name} replied to client"),
+                    format!("Reply sent for op #{op}. Request complete."),
+                ),
+            };
             Some(EventLogEntry {
                 tick,
                 icon: "[RPL]",
-                headline: format!("{name} returned the ball!"),
-                detail: format!(
-                    "{name} sent a reply to the client. The request for op 
#{op} \
-                     was committed and acknowledged. Fetch complete!"
-                ),
+                headline,
+                detail,
                 color: MessageType::ClientReply.color(),
             })
         }
@@ -332,51 +383,92 @@ pub(crate) fn narrate_event(
             let replica_id = event.replica_id.unwrap_or(0) as u8;
             let target = event.target_replica.unwrap_or(0) as u8;
             let action = event.action.as_deref().unwrap_or("");
-            let from_name = dog_name(replica_id);
-            let to_name = dog_name(target);
+            let from_name = vocab.node_name(replica_id);
+            let to_name = vocab.node_name(target);
             let msg_type = MessageType::from_action(action);
-            let (icon, headline, detail) = match msg_type {
-                Some(MessageType::PrepareOk) => (
-                    "[POK]",
-                    format!("{from_name} -> {to_name}: PrepareOk"),
-                    format!(
-                        "{from_name} confirmed to {to_name}: \"I logged op 
#{}!\" \
-                         One step closer to quorum.",
-                        event.op.unwrap_or(0)
+            let (icon, headline, detail) = match vocab {
+                VocabMode::Dog => match msg_type {
+                    Some(MessageType::PrepareOk) => (
+                        "[POK]",
+                        format!("{from_name} -> {to_name}: PrepareOk"),
+                        format!(
+                            "{from_name} confirmed to {to_name}: \"I logged op 
#{}!\" \
+                             One step closer to quorum.",
+                            event.op.unwrap_or(0)
+                        ),
                     ),
-                ),
-                Some(MessageType::StartViewChange) => (
-                    "[SVC]",
-                    format!("{from_name} howls: StartViewChange!"),
-                    format!(
-                        "{from_name} suspects the lead dog is down! 
Broadcasting view change \
-                         to view {} -- the pack needs a new leader.",
-                        event.view.unwrap_or(0)
+                    Some(MessageType::StartViewChange) => (
+                        "[SVC]",
+                        format!("{from_name} howls: StartViewChange!"),
+                        format!(
+                            "{from_name} suspects the lead dog is down! 
Broadcasting view change \
+                             to view {} -- the pack needs a new leader.",
+                            event.view.unwrap_or(0)
+                        ),
                     ),
-                ),
-                Some(MessageType::DoViewChange) => (
-                    "[DVC]",
-                    format!("{from_name} -> {to_name}: DoViewChange"),
-                    format!(
-                        "{from_name} is voting for {to_name} to become the new 
lead dog in view {}. \
-                         Sending its log state so the new leader has all 
committed data.",
-                        event.view.unwrap_or(0)
+                    Some(MessageType::DoViewChange) => (
+                        "[DVC]",
+                        format!("{from_name} -> {to_name}: DoViewChange"),
+                        format!(
+                            "{from_name} is voting for {to_name} to become the 
new lead dog in view {}. \
+                             Sending its log state so the new leader has all 
committed data.",
+                            event.view.unwrap_or(0)
+                        ),
                     ),
-                ),
-                Some(MessageType::StartView) => (
-                    "[NEW]",
-                    format!("{from_name} announces: I'm lead dog!"),
-                    format!(
-                        "{from_name} won the election for view {}! 
Broadcasting StartView to the pack -- \
-                         everyone sync up and follow the new leader.",
-                        event.view.unwrap_or(0)
+                    Some(MessageType::StartView) => (
+                        "[NEW]",
+                        format!("{from_name} announces: I'm lead dog!"),
+                        format!(
+                            "{from_name} won the election for view {}! 
Broadcasting StartView to the pack -- \
+                             everyone sync up and follow the new leader.",
+                            event.view.unwrap_or(0)
+                        ),
                     ),
-                ),
-                _ => (
-                    "[MSG]",
-                    format!("{from_name} -> {to_name}: {action}"),
-                    format!("Control message from {from_name} to {to_name}."),
-                ),
+                    _ => (
+                        "[MSG]",
+                        format!("{from_name} -> {to_name}: {action}"),
+                        format!("Control message from {from_name} to 
{to_name}."),
+                    ),
+                },
+                VocabMode::Technical => match msg_type {
+                    Some(MessageType::PrepareOk) => (
+                        "[POK]",
+                        format!("{from_name} -> {to_name}: PrepareOk"),
+                        format!(
+                            "{from_name} acknowledged op #{} to {to_name}.",
+                            event.op.unwrap_or(0)
+                        ),
+                    ),
+                    Some(MessageType::StartViewChange) => (
+                        "[SVC]",
+                        format!("{from_name} -> {to_name}: StartViewChange"),
+                        format!(
+                            "{from_name} initiated view change to view {}.",
+                            event.view.unwrap_or(0)
+                        ),
+                    ),
+                    Some(MessageType::DoViewChange) => (
+                        "[DVC]",
+                        format!("{from_name} -> {to_name}: DoViewChange"),
+                        format!(
+                            "{from_name} sending DoViewChange to {to_name} for 
view {}.",
+                            event.view.unwrap_or(0)
+                        ),
+                    ),
+                    Some(MessageType::StartView) => (
+                        "[NEW]",
+                        format!("{from_name} -> {to_name}: StartView"),
+                        format!(
+                            "{from_name} broadcasting StartView for view {}.",
+                            event.view.unwrap_or(0)
+                        ),
+                    ),
+                    _ => (
+                        "[MSG]",
+                        format!("{from_name} -> {to_name}: {action}"),
+                        format!("Control message from {from_name} to 
{to_name}."),
+                    ),
+                },
             };
             let color = msg_type.map(|mt| mt.color()).unwrap_or(DIM_GRAY);
             Some(EventLogEntry {
@@ -391,61 +483,106 @@ pub(crate) fn narrate_event(
             let replica_id = event.replica_id.unwrap_or(0) as u8;
             let old_view = event.old_view.unwrap_or(0);
             let new_view = event.new_view.unwrap_or(0);
-            let name = dog_name(replica_id);
-            let reason_text = event
-                .reason
-                .as_deref()
-                .map(|reason| match reason {
-                    "heartbeat_timeout" => format!(
-                        "{name} hasn't heard from the lead dog in too long 
(heartbeat timeout)."
-                    ),
-                    "view_change_timeout" => {
-                        "The view change itself timed out -- the pack couldn't 
agree fast enough."
-                            .to_string()
-                    }
-                    "received_start_view_change" => {
-                        format!("{name} heard another dog howling for a view 
change and joined in.")
-                    }
-                    "received_do_view_change" => {
+            let name = vocab.node_name(replica_id);
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => {
+                    let reason_text = event
+                        .reason
+                        .as_deref()
+                        .map(|reason| match reason {
+                            "heartbeat_timeout" => format!(
+                                "{name} hasn't heard from the lead dog in too 
long (heartbeat timeout)."
+                            ),
+                            "view_change_timeout" => {
+                                "The view change itself timed out -- the pack 
couldn't agree fast enough."
+                                    .to_string()
+                            }
+                            "received_start_view_change" => {
+                                format!("{name} heard another dog howling for 
a view change and joined in.")
+                            }
+                            "received_do_view_change" => {
+                                format!(
+                                    "{name} received a DoViewChange vote, 
triggering its own view change."
+                                )
+                            }
+                            other => format!("Reason: {other}"),
+                        })
+                        .unwrap_or_default();
+                    (
+                        format!("{name} started view change! (v{old_view} -> 
v{new_view})"),
                         format!(
-                            "{name} received a DoViewChange vote, triggering 
its own view change."
-                        )
-                    }
-                    other => format!("Reason: {other}"),
-                })
-                .unwrap_or_default();
+                            "{reason_text} The pack is reshuffling -- \
+                             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
+                        ),
+                    )
+                }
+                VocabMode::Technical => {
+                    let reason_text = event
+                        .reason
+                        .as_deref()
+                        .map(|reason| match reason {
+                            "heartbeat_timeout" => "Heartbeat 
timeout.".to_string(),
+                            "view_change_timeout" => "View change 
timeout.".to_string(),
+                            "received_start_view_change" => {
+                                "Received StartViewChange from 
peer.".to_string()
+                            }
+                            "received_do_view_change" => {
+                                "Received DoViewChange from peer.".to_string()
+                            }
+                            other => format!("Reason: {other}."),
+                        })
+                        .unwrap_or_default();
+                    (
+                        format!("{name} started view change (v{old_view} -> 
v{new_view})"),
+                        format!(
+                            "{reason_text} New primary will be R{} (view 
{new_view} mod {replica_count}).",
+                            new_view % replica_count as u64
+                        ),
+                    )
+                }
+            };
             Some(EventLogEntry {
                 tick,
                 icon: "[VCH]",
-                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{} \
-                     will become the new lead dog if quorum agrees.",
-                    new_view % replica_count as u64
-                ),
+                headline,
+                detail,
                 color: NEON_MAGENTA,
             })
         }
         "PrimaryElected" => {
             let replica_id = event.replica_id.unwrap_or(0) as u8;
             let view = event.view.unwrap_or(0);
-            let name = dog_name(replica_id);
+            let name = vocab.node_name(replica_id);
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => (
+                    format!("{name} is the new lead dog! (view {view})"),
+                    format!(
+                        "{name} (R{replica_id}) collected enough DoViewChange 
votes and is now \
+                         the lead dog for view {view}. The pack will follow 
{name}'s lead \
+                         for all new operations. Broadcasting StartView to 
synchronize everyone."
+                    ),
+                ),
+                VocabMode::Technical => (
+                    format!("{name} elected primary (view {view})"),
+                    format!(
+                        "{name} (R{replica_id}) elected primary for view 
{view}. \
+                         Received sufficient DoViewChange votes. Broadcasting 
StartView."
+                    ),
+                ),
+            };
             Some(EventLogEntry {
                 tick,
                 icon: "[LDR]",
-                headline: format!("{name} is the new lead dog! (view {view})"),
-                detail: format!(
-                    "{name} (R{replica_id}) collected enough DoViewChange 
votes and is now \
-                     the lead dog for view {view}. The pack will follow 
{name}'s lead \
-                     for all new operations. Broadcasting StartView to 
synchronize everyone."
-                ),
+                headline,
+                detail,
                 color: NEON_YELLOW,
             })
         }
         "ReplicaStateChanged" => {
             let replica_id = event.replica_id.unwrap_or(0) as u8;
-            let name = dog_name(replica_id);
+            let name = vocab.node_name(replica_id);
             let new_status = event
                 .status
                 .as_deref()
@@ -456,9 +593,51 @@ pub(crate) fn narrate_event(
                 .as_deref()
                 .map(Role::from_str)
                 .unwrap_or(Role::Backup);
-            let role_str = match role {
-                Role::Primary => "lead dog",
-                Role::Backup => "pack dog",
+            let (headline, detail) = match vocab {
+                VocabMode::Dog => {
+                    let role_str = match role {
+                        Role::Primary => "lead dog",
+                        Role::Backup => "pack dog",
+                    };
+                    (
+                        format!("{name} ({role_str}): -> {new_status:?}"),
+                        match new_status {
+                            Status::Normal => {
+                                format!("{name} is back to running! The pack 
is stable again.")
+                            }
+                            Status::ViewChange => {
+                                format!("{name} senses trouble -- howling for 
a new leader!")
+                            }
+                            Status::Recovering => format!(
+                                "{name} stumbled and is now recovering -- 
needs to catch up with the pack."
+                            ),
+                        },
+                    )
+                }
+                VocabMode::Technical => {
+                    let role_str = match role {
+                        Role::Primary => "primary",
+                        Role::Backup => "backup",
+                    };
+                    (
+                        format!("{name} ({role_str}): -> {new_status:?}"),
+                        match new_status {
+                            Status::Normal => {
+                                format!(
+                                    "{name} (R{replica_id}) transitioned to 
Normal. Cluster stable."
+                                )
+                            }
+                            Status::ViewChange => {
+                                format!("{name} (R{replica_id}) entered 
ViewChange state.")
+                            }
+                            Status::Recovering => {
+                                format!(
+                                    "{name} (R{replica_id}) entered Recovering 
state. Awaiting state transfer."
+                                )
+                            }
+                        },
+                    )
+                }
             };
             Some(EventLogEntry {
                 tick,
@@ -467,18 +646,8 @@ pub(crate) fn narrate_event(
                     Status::ViewChange => "[HWL]",
                     Status::Recovering => "[LMP]",
                 },
-                headline: format!("{name} ({role_str}): -> {new_status:?}"),
-                detail: match new_status {
-                    Status::Normal => {
-                        format!("{name} is back to running! The pack is stable 
again.")
-                    }
-                    Status::ViewChange => {
-                        format!("{name} senses trouble -- howling for a new 
leader!")
-                    }
-                    Status::Recovering => format!(
-                        "{name} stumbled and is now recovering -- needs to 
catch up with the pack."
-                    ),
-                },
+                headline,
+                detail,
                 color: match new_status {
                     Status::Normal => NEON_CYAN,
                     Status::ViewChange => NEON_MAGENTA,
diff --git a/core/simulator_ui/src/input.rs b/core/simulator_ui/src/input.rs
index e786f60a3..b5e5d62f1 100644
--- a/core/simulator_ui/src/input.rs
+++ b/core/simulator_ui/src/input.rs
@@ -23,11 +23,13 @@ use crate::helpers::*;
 use crate::queries::FxParams;
 use crate::resources::*;
 use crate::theme::*;
+use crate::vocabulary::Vocab;
 
 pub(crate) fn handle_selection_input(
     keys: Res<ButtonInput<KeyCode>>,
     mut state: ResMut<SelectionState>,
     mut config: ResMut<ReplicaConfig>,
+    mut vocab: ResMut<Vocab>,
     mut next_state: ResMut<NextState<AppPhase>>,
 ) {
     if keys.just_pressed(KeyCode::ArrowLeft) && state.index > 0 {
@@ -47,6 +49,10 @@ pub(crate) fn handle_selection_input(
         state.index = 2;
     }
 
+    if keys.just_pressed(KeyCode::Tab) {
+        vocab.mode = vocab.mode.toggle();
+    }
+
     if keys.just_pressed(KeyCode::Space) || keys.just_pressed(KeyCode::Enter) {
         config.count = PACK_OPTIONS[state.index];
         next_state.set(AppPhase::Simulating);
@@ -62,6 +68,7 @@ pub(crate) fn handle_keyboard_input(
     mut console: ResMut<GameConsole>,
     config: Res<ReplicaConfig>,
     positions: Res<ReplicaPositions>,
+    vocab: Res<Vocab>,
     mut fx: FxParams,
 ) {
     let replica_count = config.count;
@@ -96,7 +103,7 @@ pub(crate) fn handle_keyboard_input(
             if !state.alive {
                 continue;
             }
-            let name = dog_name(state.id);
+            let name = vocab.mode.node_name(state.id);
             sim.simulator.kill_replica(state.id);
             fx.screen_flash.timer = SCREEN_FLASH_DURATION;
             fx.screen_flash.color = NEON_MAGENTA;
@@ -105,18 +112,15 @@ pub(crate) fn handle_keyboard_input(
             trigger_replica_callout(
                 &mut fx.replica_fx,
                 state.id,
-                "GREYHOUND DOWN",
+                vocab.mode.callout_node_down(),
                 NEON_MAGENTA,
                 1.4,
             );
             event_log.push(EventLogEntry {
                 tick: sim.simulator.tick,
                 icon: "[KIL]",
-                headline: format!("{name} (R{}) was tripped!", state.id),
-                detail: format!(
-                    "{name} crashed! Network links disabled. The pack loses a 
member -- \
-                     if quorum is lost, no new operations can commit."
-                ),
+                headline: vocab.mode.kill_headline(name, state.id),
+                detail: vocab.mode.kill_detail(name),
                 color: NEON_MAGENTA,
             });
             let center = positions.0[state.id as usize];
@@ -143,7 +147,13 @@ pub(crate) fn handle_keyboard_input(
         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);
+            trigger_replica_callout(
+                &mut fx.replica_fx,
+                replica_id,
+                vocab.mode.callout_healed(),
+                NEON_CYAN,
+                1.2,
+            );
             let center = positions.0[replica_id as usize];
             commands.spawn((
                 CommitRing {
@@ -160,10 +170,8 @@ pub(crate) fn handle_keyboard_input(
         event_log.push(EventLogEntry {
             tick: sim.simulator.tick,
             icon: "[HEL]",
-            headline: "Pack healed! All dogs back, all fences 
down.".to_string(),
-            detail: "All crashed replicas re-enabled, all network partitions 
cleared. \
-                     The pack is whole again."
-                .to_string(),
+            headline: vocab.mode.heal_headline().to_string(),
+            detail: vocab.mode.heal_detail().to_string(),
             color: NEON_CYAN,
         });
     }
@@ -177,13 +185,25 @@ pub(crate) fn handle_keyboard_input(
             let first = alive[0];
             let second = alive[1];
             sim.simulator.partition_link(first, second);
-            let name_first = dog_name(first);
-            let name_second = dog_name(second);
+            let name_first = vocab.mode.node_name(first);
+            let name_second = vocab.mode.node_name(second);
             fx.screen_flash.timer = SCREEN_FLASH_DURATION * 0.5;
             fx.screen_flash.color = NEON_MAGENTA;
             fx.screen_flash.intensity = 0.04;
-            trigger_replica_callout(&mut fx.replica_fx, first, "FENCED!", 
NEON_MAGENTA, 1.3);
-            trigger_replica_callout(&mut fx.replica_fx, second, "FENCED!", 
NEON_MAGENTA, 1.3);
+            trigger_replica_callout(
+                &mut fx.replica_fx,
+                first,
+                vocab.mode.callout_fenced(),
+                NEON_MAGENTA,
+                1.3,
+            );
+            trigger_replica_callout(
+                &mut fx.replica_fx,
+                second,
+                vocab.mode.callout_fenced(),
+                NEON_MAGENTA,
+                1.3,
+            );
             for &replica_id in &[first, second] {
                 let center = positions.0[replica_id as usize];
                 commands.spawn((
@@ -201,12 +221,10 @@ pub(crate) fn handle_keyboard_input(
             event_log.push(EventLogEntry {
                 tick: sim.simulator.tick,
                 icon: "[FNC]",
-                headline: format!("Fence between {name_first} and 
{name_second}!"),
-                detail: format!(
-                    "Network partition between {name_first} (R{first}) and 
{name_second} (R{second}). \
-                     Messages between them will be dropped. If this splits 
quorum, \
-                     expect a view change."
-                ),
+                headline: vocab.mode.partition_headline(name_first, 
name_second),
+                detail: vocab
+                    .mode
+                    .partition_detail(name_first, first, name_second, second),
                 color: NEON_MAGENTA,
             });
         }
diff --git a/core/simulator_ui/src/main.rs b/core/simulator_ui/src/main.rs
index 4104cdbd7..039bf109b 100644
--- a/core/simulator_ui/src/main.rs
+++ b/core/simulator_ui/src/main.rs
@@ -29,6 +29,7 @@ mod tracing_layer;
 mod types;
 mod util;
 mod visuals;
+mod vocabulary;
 
 use bevy::asset::AssetPlugin;
 use bevy::prelude::*;
@@ -38,6 +39,7 @@ use bridge::UiSimulator;
 use resources::*;
 use theme::*;
 use tracing_layer::EventBuffer;
+use vocabulary::Vocab;
 
 #[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
 pub(crate) enum AppPhase {
@@ -123,6 +125,7 @@ fn main() {
             count: DEFAULT_REPLICA_COUNT,
         })
         .insert_resource(SelectionState::default())
+        .insert_resource(Vocab::default())
         .insert_resource(SharedBuffers { event_buffer })
         .insert_resource(ReplicaPositions::default())
         .insert_resource(ReplicaFxState::default())
diff --git a/core/simulator_ui/src/setup.rs b/core/simulator_ui/src/setup.rs
index fbcf96290..acc663a5f 100644
--- a/core/simulator_ui/src/setup.rs
+++ b/core/simulator_ui/src/setup.rs
@@ -25,6 +25,7 @@ use crate::components::*;
 use crate::helpers::*;
 use crate::resources::*;
 use crate::theme::*;
+use crate::vocabulary::{Vocab, VocabMode};
 
 pub(crate) fn setup_camera(mut commands: Commands) {
     commands.spawn(Camera2d);
@@ -251,13 +252,56 @@ pub(crate) fn setup_selection_screen(mut commands: 
Commands, asset_server: Res<A
                 flex_direction: FlexDirection::Row,
                 column_gap: Val::Px(20.0),
                 align_items: AlignItems::Center,
-                margin: UiRect::top(Val::Px(12.0)),
+                margin: UiRect::top(Val::Px(8.0)),
+                ..default()
+            })
+            .with_children(|row| {
+                row.spawn((
+                    Text::new("STYLE"),
+                    TextFont {
+                        font_size: 11.0,
+                        ..default()
+                    },
+                    TextColor(DIM_GRAY),
+                ));
+
+                for (is_dog, label) in [(true, "FUN"), (false, "TECHNICAL")] {
+                    row.spawn((
+                        VocabCard { is_dog },
+                        Node {
+                            padding: UiRect::axes(Val::Px(14.0), Val::Px(6.0)),
+                            border: UiRect::all(Val::Px(1.0)),
+                            ..default()
+                        },
+                        BackgroundColor(Color::srgba_u8(0x0c, 0x14, 0x24, 
0xcc)),
+                        BorderColor::all(DIM_GRAY.with_alpha(0.2)),
+                    ))
+                    .with_children(|card| {
+                        card.spawn((
+                            VocabLabel { is_dog },
+                            Text::new(label),
+                            TextFont {
+                                font_size: 14.0,
+                                ..default()
+                            },
+                            TextColor(DIM_GRAY.with_alpha(0.4)),
+                        ));
+                    });
+                }
+            });
+
+            root.spawn(Node {
+                flex_direction: FlexDirection::Row,
+                column_gap: Val::Px(20.0),
+                align_items: AlignItems::Center,
+                margin: UiRect::top(Val::Px(10.0)),
                 ..default()
             })
             .with_children(|hint| {
                 for (key, action, color) in [
-                    ("< >", "select", NEON_CYAN),
-                    ("SPACE", "unleash the pack!", IGGY_ORANGE),
+                    ("< >", "select pack", NEON_CYAN),
+                    ("TAB", "switch style", NEON_YELLOW),
+                    ("SPACE", "start!", IGGY_ORANGE),
                 ] {
                     hint.spawn(Node {
                         flex_direction: FlexDirection::Row,
@@ -279,7 +323,7 @@ pub(crate) fn setup_selection_screen(mut commands: 
Commands, asset_server: Res<A
                             badge.spawn((
                                 Text::new(key),
                                 TextFont {
-                                    font_size: 15.0,
+                                    font_size: 14.0,
                                     ..default()
                                 },
                                 TextColor(color),
@@ -299,29 +343,56 @@ pub(crate) fn setup_selection_screen(mut commands: 
Commands, asset_server: Res<A
         });
 }
 
-#[allow(clippy::type_complexity)]
+#[allow(clippy::type_complexity, clippy::too_many_arguments)]
 pub(crate) fn update_selection_screen(
     time: Res<Time>,
     state: Res<SelectionState>,
+    vocab: Res<Vocab>,
     mut cards: Query<
         (&SelectionCard, &mut BorderColor, &mut BackgroundColor),
-        Without<SelectionNumber>,
+        (Without<SelectionNumber>, Without<VocabCard>),
     >,
     mut numbers: Query<
         (&SelectionNumber, &mut TextColor),
-        (Without<SelectionCard>, Without<SelectionLabel>),
+        (
+            Without<SelectionCard>,
+            Without<SelectionLabel>,
+            Without<SelectionTitle>,
+            Without<VocabLabel>,
+        ),
     >,
     mut labels: Query<
-        (&SelectionLabel, &mut TextColor),
-        (Without<SelectionCard>, Without<SelectionNumber>),
+        (&SelectionLabel, &mut Text, &mut TextColor),
+        (
+            Without<SelectionCard>,
+            Without<SelectionNumber>,
+            Without<SelectionTitle>,
+            Without<VocabLabel>,
+        ),
     >,
     mut title: Query<
-        &mut TextColor,
+        (&mut Text, &mut TextColor),
         (
             With<SelectionTitle>,
             Without<SelectionCard>,
             Without<SelectionNumber>,
             Without<SelectionLabel>,
+            Without<VocabCard>,
+            Without<VocabLabel>,
+        ),
+    >,
+    mut vocab_cards: Query<
+        (&VocabCard, &mut BorderColor, &mut BackgroundColor),
+        (Without<SelectionCard>, Without<VocabLabel>),
+    >,
+    mut vocab_labels: Query<
+        (&VocabLabel, &mut TextColor),
+        (
+            Without<SelectionCard>,
+            Without<SelectionLabel>,
+            Without<SelectionNumber>,
+            Without<SelectionTitle>,
+            Without<VocabCard>,
         ),
     >,
 ) {
@@ -329,8 +400,10 @@ pub(crate) fn update_selection_screen(
     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;
+    let is_dog = vocab.mode == VocabMode::Dog;
 
-    for mut color in title.iter_mut() {
+    for (mut text, mut color) in title.iter_mut() {
+        **text = vocab.mode.choose_label().to_string();
         *color = TextColor(NEON_CYAN.with_alpha(0.7 + slow_pulse * 0.3));
     }
 
@@ -354,13 +427,34 @@ pub(crate) fn update_selection_screen(
         }
     }
 
-    for (label, mut color) in labels.iter_mut() {
+    for (label, mut text, mut color) in labels.iter_mut() {
+        **text = vocab.mode.pack_option_name(label.index).to_string();
         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));
         }
     }
+
+    for (vc, mut border, mut bg) in vocab_cards.iter_mut() {
+        let selected = vc.is_dog == is_dog;
+        if selected {
+            *border = BorderColor::all(NEON_YELLOW.with_alpha(0.6 + fast_pulse 
* 0.4));
+            *bg = BackgroundColor(NEON_YELLOW.with_alpha(0.06 + pulse * 0.04));
+        } else {
+            *border = BorderColor::all(Color::srgba_u8(0x22, 0x2a, 0x3a, 
0x44));
+            *bg = BackgroundColor(Color::srgba_u8(0x08, 0x0e, 0x1a, 0x88));
+        }
+    }
+
+    for (vl, mut color) in vocab_labels.iter_mut() {
+        let selected = vl.is_dog == is_dog;
+        if selected {
+            *color = TextColor(NEON_YELLOW.with_alpha(0.85 + fast_pulse * 
0.15));
+        } else {
+            *color = TextColor(DIM_GRAY.with_alpha(0.25));
+        }
+    }
 }
 
 pub(crate) fn despawn_selection_screen(
@@ -399,6 +493,7 @@ pub(crate) fn setup_simulation_world(
     config: Res<ReplicaConfig>,
     positions: Res<ReplicaPositions>,
     asset_server: Res<AssetServer>,
+    vocab: Res<Vocab>,
 ) {
     let replica_count = config.count;
     let mascot_handle: Handle<Image> = asset_server.load("iggy.png");
@@ -561,7 +656,7 @@ pub(crate) fn setup_simulation_world(
     let apps = [
         (
             0usize,
-            "BALL THROWER",
+            vocab.mode.app_name(0),
             AppKind::Producer,
             Vec2::new(-560.0, 330.0),
             0u8,
@@ -570,7 +665,7 @@ pub(crate) fn setup_simulation_world(
         ),
         (
             1usize,
-            "BALL FETCHER",
+            vocab.mode.app_name(1),
             AppKind::Consumer,
             Vec2::new(560.0, 330.0),
             1u8.min(replica_count - 1),
@@ -579,7 +674,7 @@ pub(crate) fn setup_simulation_world(
         ),
         (
             2usize,
-            "TREAT DISPENSER",
+            vocab.mode.app_name(2),
             AppKind::Producer,
             Vec2::new(-560.0, 70.0),
             2u8.min(replica_count - 1),
@@ -588,7 +683,7 @@ pub(crate) fn setup_simulation_world(
         ),
         (
             3usize,
-            "TREAT GOBBLER",
+            vocab.mode.app_name(3),
             AppKind::Consumer,
             Vec2::new(560.0, 70.0),
             0u8,
@@ -646,13 +741,15 @@ pub(crate) fn setup_hud(
     mut commands: Commands,
     asset_server: Res<AssetServer>,
     config: Res<ReplicaConfig>,
+    vocab: Res<Vocab>,
 ) {
     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);
+    let v = vocab.mode;
+    let node_unit_label = format!("{} {}", config.count, v.node_unit());
 
     commands
         .spawn(Node {
@@ -684,7 +781,7 @@ pub(crate) fn setup_hud(
                 })
                 .with_children(|left| {
                     left.spawn((
-                        Text::new("iggy::pack"),
+                        Text::new(v.hud_title()),
                         TextFont {
                             font_size: 16.0,
                             ..default()
@@ -699,7 +796,7 @@ pub(crate) fn setup_hud(
                     })
                     .with_children(|stat| {
                         stat.spawn((
-                            Text::new("TREATS"),
+                            Text::new(v.hud_commits_label()),
                             TextFont {
                                 font_size: 9.0,
                                 ..default()
@@ -724,7 +821,7 @@ pub(crate) fn setup_hud(
                     })
                     .with_children(|stat| {
                         stat.spawn((
-                            Text::new("LAPS/S"),
+                            Text::new(v.hud_ops_label()),
                             TextFont {
                                 font_size: 9.0,
                                 ..default()
@@ -793,7 +890,7 @@ pub(crate) fn setup_hud(
                         .with_children(|col| {
                             col.spawn((
                                 HudStateText,
-                                Text::new("RACING"),
+                                Text::new(v.hud_playing()),
                                 TextFont {
                                     font_size: 16.0,
                                     ..default()
@@ -836,7 +933,7 @@ pub(crate) fn setup_hud(
                 })
                 .with_children(|col| {
                     col.spawn((
-                        Text::new("APACHE IGGY SIMULATOR"),
+                        Text::new("APACHE IGGY VSR SIMULATOR"),
                         TextFont {
                             font_size: 16.0,
                             ..default()
@@ -844,7 +941,7 @@ pub(crate) fn setup_hud(
                         TextColor(COOL_WHITE),
                     ));
                     col.spawn((
-                        Text::new("Italian greyhounds racing to consensus"),
+                        Text::new(v.subtitle()),
                         TextFont {
                             font_size: 12.0,
                             ..default()
@@ -861,9 +958,9 @@ pub(crate) fn setup_hud(
                 })
                 .with_children(|keys| {
                     for (key, action, color) in [
-                        ("SPACE", "race/rest", NEON_CYAN),
-                        ("R", "throw ball", IGGY_ORANGE),
-                        ("K", "trip dog", HUD_ALERT),
+                        ("SPACE", v.action_toggle(), NEON_CYAN),
+                        ("R", v.action_inject(), IGGY_ORANGE),
+                        ("K", v.action_kill(), HUD_ALERT),
                         ("P", "partition", NEON_MAGENTA),
                         ("H", "heal all", NEON_CYAN),
                         ("UP/DN", "speed", NEON_YELLOW),
@@ -916,7 +1013,7 @@ pub(crate) fn setup_hud(
                 })
                 .with_children(|chips| {
                     for (label, color) in [
-                        (greyhound_label.as_str(), IGGY_ORANGE),
+                        (node_unit_label.as_str(), IGGY_ORANGE),
                         ("FAULT READY", HUD_ALERT),
                     ] {
                         chips
@@ -960,7 +1057,7 @@ pub(crate) fn setup_hud(
             .with_children(|col| {
                 col.spawn((
                     PauseMainText,
-                    Text::new("RESTING"),
+                    Text::new(v.pause_title()),
                     TextFont {
                         font_size: 52.0,
                         ..default()
@@ -969,7 +1066,7 @@ pub(crate) fn setup_hud(
                 ));
                 col.spawn((
                     PauseSubtext,
-                    Text::new("press SPACE to unleash"),
+                    Text::new(v.pause_hint()),
                     TextFont {
                         font_size: 16.0,
                         ..default()
@@ -997,7 +1094,7 @@ pub(crate) fn setup_hud(
             ))
             .with_children(|panel| {
                 panel.spawn((
-                    Text::new("PACK ACTIVITY LOG"),
+                    Text::new(v.event_log_title()),
                     TextFont {
                         font_size: 16.0,
                         ..default()
diff --git a/core/simulator_ui/src/simulation.rs 
b/core/simulator_ui/src/simulation.rs
index d8f102d5c..87bc4a986 100644
--- a/core/simulator_ui/src/simulation.rs
+++ b/core/simulator_ui/src/simulation.rs
@@ -27,7 +27,9 @@ use crate::resources::*;
 use crate::theme::*;
 use crate::tracing_layer::CapturedSimEvent;
 use crate::types::{MessageType, Status};
+use crate::vocabulary::{Vocab, VocabMode};
 
+#[allow(clippy::too_many_arguments)]
 pub(crate) fn tick_simulation(
     mut commands: Commands,
     time: Res<Time>,
@@ -36,6 +38,7 @@ pub(crate) fn tick_simulation(
     positions: Res<ReplicaPositions>,
     mut fx: FxParams,
     mut event_log: ResMut<EventLog>,
+    vocab: Res<Vocab>,
 ) {
     let elapsed_secs = time.elapsed_secs_f64();
     if elapsed_secs - sim.ops_window_start >= 1.0 {
@@ -60,7 +63,7 @@ pub(crate) fn tick_simulation(
         let events = sim.simulator.step();
 
         for event in &events {
-            if let Some(entry) = narrate_event(event, tick, replica_count) {
+            if let Some(entry) = narrate_event(event, tick, replica_count, 
vocab.mode) {
                 event_log.push(entry);
             }
             match event.sim_event.as_str() {
@@ -84,6 +87,7 @@ pub(crate) fn tick_simulation(
                         &mut fx.replica_fx,
                         &mut fx.app_fx,
                         &mut fx.track_pulse,
+                        vocab.mode,
                     );
                 }
                 "ViewChangeStarted" => {
@@ -96,6 +100,7 @@ pub(crate) fn tick_simulation(
                         &mut fx.app_fx,
                         &mut fx.screen_flash,
                         replica_count,
+                        vocab.mode,
                     );
                 }
                 "ClientRequestReceived" => {
@@ -109,6 +114,7 @@ pub(crate) fn tick_simulation(
                         &positions,
                         &mut fx.replica_fx,
                         &mut fx.screen_flash,
+                        vocab.mode,
                     );
                 }
                 "ReplicaStateChanged" => {
@@ -119,9 +125,9 @@ pub(crate) fn tick_simulation(
                         .map(Status::from_str)
                         .unwrap_or(Status::Normal);
                     let (label, color, duration) = match new_status {
-                        Status::Normal => ("RUNNING!", NEON_CYAN, 0.85),
-                        Status::ViewChange => ("HOWLING!", NEON_MAGENTA, 1.1),
-                        Status::Recovering => ("LIMPING...", NEON_YELLOW, 1.1),
+                        Status::Normal => (vocab.mode.callout_normal(), 
NEON_CYAN, 0.85),
+                        Status::ViewChange => 
(vocab.mode.callout_view_change(), NEON_MAGENTA, 1.1),
+                        Status::Recovering => 
(vocab.mode.callout_recovering(), NEON_YELLOW, 1.1),
                     };
                     trigger_replica_callout(&mut fx.replica_fx, replica_id, 
label, color, duration);
                 }
@@ -239,6 +245,7 @@ pub(crate) fn spawn_prepare_particles(
     }
 }
 
+#[allow(clippy::too_many_arguments)]
 pub(crate) fn handle_commit_event(
     commands: &mut Commands,
     event: &CapturedSimEvent,
@@ -247,12 +254,19 @@ pub(crate) fn handle_commit_event(
     replica_fx: &mut ResMut<ReplicaFxState>,
     app_fx: &mut ResMut<AppFxState>,
     track_pulse: &mut ResMut<TrackPulse>,
+    vocab: VocabMode,
 ) {
     let replica_id = event.replica_id.unwrap_or(0) as u8;
     sim.total_commits += 1;
     sim.ops_window_count += 1;
     replica_fx.healthy[replica_id as usize] = 0.45;
-    trigger_replica_callout(replica_fx, replica_id, "GOOD BOY!", IGGY_ORANGE, 
0.9);
+    trigger_replica_callout(
+        replica_fx,
+        replica_id,
+        vocab.callout_committed(),
+        IGGY_ORANGE,
+        0.9,
+    );
     app_fx.pulse[2] = 0.55;
     track_pulse.energy = (track_pulse.energy + 0.4).min(1.0);
     commands.spawn(GlowFlash {
@@ -364,9 +378,16 @@ pub(crate) fn handle_view_change_event(
     app_fx: &mut ResMut<AppFxState>,
     screen_flash: &mut ResMut<ScreenFlash>,
     replica_count: u8,
+    vocab: VocabMode,
 ) {
     let replica_id = event.replica_id.unwrap_or(0) as u8;
-    trigger_replica_callout(replica_fx, replica_id, "PACK SHUFFLE!", 
NEON_MAGENTA, 1.15);
+    trigger_replica_callout(
+        replica_fx,
+        replica_id,
+        vocab.callout_view_change_started(),
+        NEON_MAGENTA,
+        1.15,
+    );
     app_fx.pulse[3] = 0.8;
     screen_flash.timer = SCREEN_FLASH_DURATION;
     screen_flash.color = NEON_MAGENTA;
@@ -435,9 +456,16 @@ pub(crate) fn handle_primary_elected_event(
     positions: &ReplicaPositions,
     replica_fx: &mut ResMut<ReplicaFxState>,
     screen_flash: &mut ResMut<ScreenFlash>,
+    vocab: VocabMode,
 ) {
     let replica_id = event.replica_id.unwrap_or(0) as u8;
-    trigger_replica_callout(replica_fx, replica_id, "NEW LEAD DOG!", 
NEON_YELLOW, 1.2);
+    trigger_replica_callout(
+        replica_fx,
+        replica_id,
+        vocab.callout_primary_elected(),
+        NEON_YELLOW,
+        1.2,
+    );
     screen_flash.timer = SCREEN_FLASH_DURATION * 0.7;
     screen_flash.color = NEON_YELLOW;
     screen_flash.intensity = 0.08;
diff --git a/core/simulator_ui/src/visuals.rs b/core/simulator_ui/src/visuals.rs
index 87129fdfe..1091a1e18 100644
--- a/core/simulator_ui/src/visuals.rs
+++ b/core/simulator_ui/src/visuals.rs
@@ -26,16 +26,18 @@ use crate::queries::*;
 use crate::resources::*;
 use crate::theme::*;
 use crate::types::{Role, Status};
+use crate::vocabulary::Vocab;
 
 #[allow(clippy::type_complexity)]
 pub(crate) fn update_replica_visuals(
-    (time, sim, config, positions, mut replica_fx, mut commands): (
+    (time, sim, config, positions, mut replica_fx, mut commands, vocab): (
         Res<Time>,
         NonSend<SimulationState>,
         Res<ReplicaConfig>,
         Res<ReplicaPositions>,
         ResMut<ReplicaFxState>,
         Commands,
+        Res<Vocab>,
     ),
     mut flash_query: Query<(Entity, &mut GlowFlash)>,
     mut shape_visuals: ParamSet<(
@@ -91,10 +93,22 @@ pub(crate) fn update_replica_visuals(
             if state.alive {
                 replica_fx.revive[idx] = 1.0;
                 replica_fx.healthy[idx] = 0.6;
-                trigger_replica_callout(&mut replica_fx, id, "BACK ON TRACK!", 
IGGY_ORANGE, 1.3);
+                trigger_replica_callout(
+                    &mut replica_fx,
+                    id,
+                    vocab.mode.callout_recovered(),
+                    IGGY_ORANGE,
+                    1.3,
+                );
             } else {
                 replica_fx.kill[idx] = 1.0;
-                trigger_replica_callout(&mut replica_fx, id, "GREYHOUND DOWN", 
NEON_MAGENTA, 1.4);
+                trigger_replica_callout(
+                    &mut replica_fx,
+                    id,
+                    vocab.mode.callout_node_down(),
+                    NEON_MAGENTA,
+                    1.4,
+                );
             }
         }
 
@@ -103,14 +117,32 @@ pub(crate) fn update_replica_visuals(
             match state.status {
                 Status::Normal => {
                     replica_fx.healthy[idx] = 0.5;
-                    trigger_replica_callout(&mut replica_fx, id, "RUNNING!", 
NEON_CYAN, 0.8);
+                    trigger_replica_callout(
+                        &mut replica_fx,
+                        id,
+                        vocab.mode.callout_normal(),
+                        NEON_CYAN,
+                        0.8,
+                    );
                 }
                 Status::Recovering => {
                     replica_fx.revive[idx] = 0.8;
-                    trigger_replica_callout(&mut replica_fx, id, "LIMPING...", 
NEON_YELLOW, 1.1);
+                    trigger_replica_callout(
+                        &mut replica_fx,
+                        id,
+                        vocab.mode.callout_recovering(),
+                        NEON_YELLOW,
+                        1.1,
+                    );
                 }
                 Status::ViewChange => {
-                    trigger_replica_callout(&mut replica_fx, id, "HOWLING!", 
NEON_MAGENTA, 1.1);
+                    trigger_replica_callout(
+                        &mut replica_fx,
+                        id,
+                        vocab.mode.callout_view_change(),
+                        NEON_MAGENTA,
+                        1.1,
+                    );
                 }
             }
         }
@@ -275,9 +307,9 @@ pub(crate) fn update_replica_visuals(
                 continue;
             }
             let label = if !state.alive {
-                "NAPPING"
+                vocab.mode.callout_crashed()
             } else {
-                role_label(state.role, state.status)
+                vocab.mode.role_label(state.role, state.status)
             };
             **text = label.to_string();
             let status_pulse = ((elapsed * 4.2 + id as f32 * 0.7).sin() * 0.5) 
+ 0.5;
@@ -303,12 +335,12 @@ pub(crate) fn update_replica_visuals(
 
             let timer = replica_fx.callout_timer[idx];
             let persistent = if !state.alive {
-                Some(("NAPPING", NEON_MAGENTA))
+                Some((vocab.mode.callout_crashed(), NEON_MAGENTA))
             } else {
                 match state.status {
                     Status::Normal => None,
-                    Status::ViewChange => Some(("HOWLING!", NEON_MAGENTA)),
-                    Status::Recovering => Some(("LIMPING...", NEON_YELLOW)),
+                    Status::ViewChange => 
Some((vocab.mode.callout_view_change(), NEON_MAGENTA)),
+                    Status::Recovering => 
Some((vocab.mode.callout_recovering(), NEON_YELLOW)),
                 }
             };
 
@@ -588,6 +620,7 @@ pub(crate) fn update_app_flow_particles(
 
 pub(crate) fn update_hud_text(
     sim: NonSend<SimulationState>,
+    vocab: Res<Vocab>,
     mut hud: ParamSet<(
         HudTickQuery,
         HudOpsQuery,
@@ -610,10 +643,10 @@ pub(crate) fn update_hud_text(
 
     for (mut text, mut color) in hud.p3().iter_mut() {
         if sim.playing {
-            **text = "RACING".to_string();
+            **text = vocab.mode.hud_playing().to_string();
             *color = TextColor(NEON_CYAN);
         } else {
-            **text = "RESTING".to_string();
+            **text = vocab.mode.hud_paused().to_string();
             *color = TextColor(NEON_YELLOW);
         }
     }
diff --git a/core/simulator_ui/src/vocabulary.rs 
b/core/simulator_ui/src/vocabulary.rs
new file mode 100644
index 000000000..f6b17e0eb
--- /dev/null
+++ b/core/simulator_ui/src/vocabulary.rs
@@ -0,0 +1,392 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use bevy::prelude::*;
+
+use crate::types::{Role, Status};
+
+#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
+pub(crate) enum VocabMode {
+    #[default]
+    Dog,
+    Technical,
+}
+
+#[derive(Resource)]
+pub(crate) struct Vocab {
+    pub(crate) mode: VocabMode,
+}
+
+impl Default for Vocab {
+    fn default() -> Self {
+        Self {
+            mode: VocabMode::Dog,
+        }
+    }
+}
+
+#[allow(dead_code)]
+impl VocabMode {
+    pub(crate) fn node_name(self, id: u8) -> &'static str {
+        match self {
+            Self::Dog => match id {
+                0 => "Iggy",
+                1 => "Zippy",
+                2 => "Dash",
+                3 => "Bolt",
+                4 => "Flash",
+                5 => "Storm",
+                6 => "Blaze",
+                _ => "Pup",
+            },
+            Self::Technical => match id {
+                0 => "R0",
+                1 => "R1",
+                2 => "R2",
+                3 => "R3",
+                4 => "R4",
+                5 => "R5",
+                6 => "R6",
+                _ => "RN",
+            },
+        }
+    }
+
+    pub(crate) fn primary(self) -> &'static str {
+        match self {
+            Self::Dog => "LEAD DOG",
+            Self::Technical => "PRIMARY",
+        }
+    }
+
+    pub(crate) fn backup(self) -> &'static str {
+        match self {
+            Self::Dog => "PACK DOG",
+            Self::Technical => "BACKUP",
+        }
+    }
+
+    pub(crate) fn role_label(self, role: Role, status: Status) -> &'static str 
{
+        match self {
+            Self::Dog => match (role, status) {
+                (Role::Primary, Status::Normal) => "LEAD DOG / running",
+                (Role::Primary, Status::ViewChange) => "LEAD DOG / howling",
+                (Role::Primary, Status::Recovering) => "LEAD DOG / limping",
+                (Role::Backup, Status::Normal) => "PACK DOG / running",
+                (Role::Backup, Status::ViewChange) => "PACK DOG / howling",
+                (Role::Backup, Status::Recovering) => "PACK DOG / limping",
+            },
+            Self::Technical => match (role, status) {
+                (Role::Primary, Status::Normal) => "PRIMARY / normal",
+                (Role::Primary, Status::ViewChange) => "PRIMARY / view-change",
+                (Role::Primary, Status::Recovering) => "PRIMARY / recovering",
+                (Role::Backup, Status::Normal) => "BACKUP / normal",
+                (Role::Backup, Status::ViewChange) => "BACKUP / view-change",
+                (Role::Backup, Status::Recovering) => "BACKUP / recovering",
+            },
+        }
+    }
+
+    pub(crate) fn callout_normal(self) -> &'static str {
+        match self {
+            Self::Dog => "RUNNING!",
+            Self::Technical => "NORMAL",
+        }
+    }
+
+    pub(crate) fn callout_view_change(self) -> &'static str {
+        match self {
+            Self::Dog => "HOWLING!",
+            Self::Technical => "VIEW-CHANGE",
+        }
+    }
+
+    pub(crate) fn callout_recovering(self) -> &'static str {
+        match self {
+            Self::Dog => "LIMPING...",
+            Self::Technical => "RECOVERING",
+        }
+    }
+
+    pub(crate) fn callout_crashed(self) -> &'static str {
+        match self {
+            Self::Dog => "NAPPING",
+            Self::Technical => "CRASHED",
+        }
+    }
+
+    pub(crate) fn callout_committed(self) -> &'static str {
+        match self {
+            Self::Dog => "GOOD BOY!",
+            Self::Technical => "COMMITTED",
+        }
+    }
+
+    pub(crate) fn callout_node_down(self) -> &'static str {
+        match self {
+            Self::Dog => "GREYHOUND DOWN",
+            Self::Technical => "NODE CRASHED",
+        }
+    }
+
+    pub(crate) fn callout_view_change_started(self) -> &'static str {
+        match self {
+            Self::Dog => "PACK SHUFFLE!",
+            Self::Technical => "VIEW CHANGE",
+        }
+    }
+
+    pub(crate) fn callout_primary_elected(self) -> &'static str {
+        match self {
+            Self::Dog => "NEW LEAD DOG!",
+            Self::Technical => "PRIMARY ELECTED",
+        }
+    }
+
+    pub(crate) fn callout_recovered(self) -> &'static str {
+        match self {
+            Self::Dog => "BACK ON TRACK!",
+            Self::Technical => "NODE RECOVERED",
+        }
+    }
+
+    pub(crate) fn callout_healed(self) -> &'static str {
+        match self {
+            Self::Dog => "HEALED!",
+            Self::Technical => "RECOVERED",
+        }
+    }
+
+    pub(crate) fn callout_fenced(self) -> &'static str {
+        match self {
+            Self::Dog => "FENCED!",
+            Self::Technical => "PARTITIONED",
+        }
+    }
+
+    pub(crate) fn hud_title(self) -> &'static str {
+        match self {
+            Self::Dog => "iggy::pack",
+            Self::Technical => "vsr::cluster",
+        }
+    }
+
+    pub(crate) fn hud_commits_label(self) -> &'static str {
+        match self {
+            Self::Dog => "TREATS",
+            Self::Technical => "COMMITS",
+        }
+    }
+
+    pub(crate) fn hud_ops_label(self) -> &'static str {
+        match self {
+            Self::Dog => "LAPS/S",
+            Self::Technical => "OPS/S",
+        }
+    }
+
+    pub(crate) fn hud_playing(self) -> &'static str {
+        match self {
+            Self::Dog => "RACING",
+            Self::Technical => "RUNNING",
+        }
+    }
+
+    pub(crate) fn hud_paused(self) -> &'static str {
+        match self {
+            Self::Dog => "RESTING",
+            Self::Technical => "PAUSED",
+        }
+    }
+
+    pub(crate) fn node_unit(self) -> &'static str {
+        match self {
+            Self::Dog => "GREYHOUNDS",
+            Self::Technical => "REPLICAS",
+        }
+    }
+
+    pub(crate) fn action_toggle(self) -> &'static str {
+        match self {
+            Self::Dog => "race/rest",
+            Self::Technical => "start/pause",
+        }
+    }
+
+    pub(crate) fn action_inject(self) -> &'static str {
+        match self {
+            Self::Dog => "throw ball",
+            Self::Technical => "inject request",
+        }
+    }
+
+    pub(crate) fn action_kill(self) -> &'static str {
+        match self {
+            Self::Dog => "trip dog",
+            Self::Technical => "crash node",
+        }
+    }
+
+    pub(crate) fn app_name(self, id: usize) -> &'static str {
+        match self {
+            Self::Dog => match id {
+                0 => "BALL THROWER",
+                1 => "BALL FETCHER",
+                2 => "TREAT DISPENSER",
+                _ => "TREAT GOBBLER",
+            },
+            Self::Technical => match id {
+                0 => "PRODUCER A",
+                1 => "CONSUMER A",
+                2 => "PRODUCER B",
+                _ => "CONSUMER B",
+            },
+        }
+    }
+
+    pub(crate) fn event_log_title(self) -> &'static str {
+        match self {
+            Self::Dog => "PACK ACTIVITY LOG",
+            Self::Technical => "EVENT LOG",
+        }
+    }
+
+    pub(crate) fn pause_title(self) -> &'static str {
+        match self {
+            Self::Dog => "RESTING",
+            Self::Technical => "PAUSED",
+        }
+    }
+
+    pub(crate) fn pause_hint(self) -> &'static str {
+        match self {
+            Self::Dog => "press SPACE to unleash",
+            Self::Technical => "press SPACE to start",
+        }
+    }
+
+    pub(crate) fn subtitle(self) -> &'static str {
+        match self {
+            Self::Dog => "Italian greyhounds racing to consensus",
+            Self::Technical => "Viewstamped Replication consensus protocol",
+        }
+    }
+
+    pub(crate) fn choose_label(self) -> &'static str {
+        match self {
+            Self::Dog => "CHOOSE YOUR PACK",
+            Self::Technical => "SELECT REPLICA COUNT",
+        }
+    }
+
+    pub(crate) fn pack_option_name(self, idx: usize) -> &'static str {
+        match self {
+            Self::Dog => match idx {
+                0 => "TIGHT PACK",
+                1 => "BALANCED",
+                _ => "FULL PACK",
+            },
+            Self::Technical => match idx {
+                0 => "MINIMAL",
+                1 => "BALANCED",
+                _ => "MAXIMUM",
+            },
+        }
+    }
+
+    pub(crate) fn confirm_action(self) -> &'static str {
+        match self {
+            Self::Dog => "unleash the pack!",
+            Self::Technical => "start simulation",
+        }
+    }
+
+    pub(crate) fn kill_headline(self, name: &str, id: u8) -> String {
+        match self {
+            Self::Dog => format!("{name} (R{id}) was tripped!"),
+            Self::Technical => format!("Node R{id} crashed"),
+        }
+    }
+
+    pub(crate) fn kill_detail(self, name: &str) -> String {
+        match self {
+            Self::Dog => format!(
+                "{name} crashed! Network links disabled. The pack loses a 
member -- \
+                 if quorum is lost, no new operations can commit."
+            ),
+            Self::Technical => format!(
+                "Node {name} crashed. Network links disabled. \
+                 If quorum is lost, commits will stall."
+            ),
+        }
+    }
+
+    pub(crate) fn heal_headline(self) -> &'static str {
+        match self {
+            Self::Dog => "Pack healed! All dogs back, all fences down.",
+            Self::Technical => "All nodes recovered. Partitions cleared.",
+        }
+    }
+
+    pub(crate) fn heal_detail(self) -> &'static str {
+        match self {
+            Self::Dog => {
+                "All crashed replicas re-enabled, all network partitions 
cleared. \
+                 The pack is whole again."
+            }
+            Self::Technical => {
+                "All crashed replicas re-enabled. All network partitions 
cleared. \
+                 Cluster is fully connected."
+            }
+        }
+    }
+
+    pub(crate) fn partition_headline(self, a: &str, b: &str) -> String {
+        match self {
+            Self::Dog => format!("Fence between {a} and {b}!"),
+            Self::Technical => format!("Network partition: {a} <-> {b}"),
+        }
+    }
+
+    pub(crate) fn partition_detail(self, a: &str, a_id: u8, b: &str, b_id: u8) 
-> String {
+        match self {
+            Self::Dog => format!(
+                "Network partition between {a} (R{a_id}) and {b} (R{b_id}). \
+                 Messages between them will be dropped. If this splits quorum, 
\
+                 expect a view change."
+            ),
+            Self::Technical => format!(
+                "Bidirectional partition between {a} (R{a_id}) and {b} 
(R{b_id}). \
+                 Messages dropped. May trigger view change if quorum is split."
+            ),
+        }
+    }
+
+    pub(crate) fn mode_label(self) -> &'static str {
+        match self {
+            Self::Dog => "FUN",
+            Self::Technical => "TECHNICAL",
+        }
+    }
+
+    pub(crate) fn toggle(self) -> Self {
+        match self {
+            Self::Dog => Self::Technical,
+            Self::Technical => Self::Dog,
+        }
+    }
+}

Reply via email to