use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::types::*;
const BROAD_CALM_RESTORE: i32 = 8;
const MEDICAL_AUTHORITY_RESTORE: i32 = 6;
const REDIRECT_RESTORE: i32 = 7;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrowdState {
pub collective_nerve: i32,
pub max_nerve: i32,
pub momentum: i32,
pub phase: CrowdPhase,
pub ringleaders: Vec<Ringleader>,
pub target_safe: bool,
pub turn: u32,
pub surge_countdown: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CrowdPhase {
Tense,
Surging,
Calming,
Broken,
Dispersed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ringleader {
pub id: String,
pub name: String,
pub nerve: i32,
pub influence: i32, pub broken: bool,
}
#[derive(Debug, Clone)]
pub struct CrowdAction {
pub actor: String,
pub action_type: CrowdActionType,
pub target: Option<String>, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrowdActionType {
BroadCalm,
TargetedNerve,
PhysicalHold,
ShockAction,
MedicalAuthority,
Redirect,
Rebuke,
}
#[derive(Debug, Clone)]
pub struct CrowdActionResult {
pub actor: String,
pub description: String,
pub nerve_change: i32,
pub momentum_change: i32,
pub ringleader_broken: Option<String>,
pub surge_delayed: bool,
}
impl CrowdState {
pub fn new(collective_nerve: i32, surge_countdown: u32, ringleaders: Vec<Ringleader>) -> Self {
Self {
collective_nerve,
max_nerve: collective_nerve.max(1),
momentum: 0,
phase: CrowdPhase::Tense,
ringleaders,
target_safe: true,
turn: 0,
surge_countdown,
}
}
pub fn execute_action(&mut self, action: &CrowdAction) -> CrowdActionResult {
self.turn += 1;
let mut result = CrowdActionResult {
actor: action.actor.clone(),
description: String::new(),
nerve_change: 0,
momentum_change: 0,
ringleader_broken: None,
surge_delayed: false,
};
match action.action_type {
CrowdActionType::BroadCalm => {
let calm = BROAD_CALM_RESTORE;
self.collective_nerve = (self.collective_nerve + calm).min(self.max_nerve);
self.momentum += 2;
result.nerve_change = calm;
result.momentum_change = 2;
result.description = format!("{} calms the crowd — collective nerve restored", action.actor);
}
CrowdActionType::TargetedNerve => {
if let Some(target_id) = &action.target {
if let Some(rl) = self.ringleaders.iter_mut().find(|r| r.id == *target_id) {
rl.nerve -= 10;
if rl.nerve <= 0 && !rl.broken {
rl.broken = true;
let calm = rl.influence;
self.collective_nerve = (self.collective_nerve + calm).min(self.max_nerve);
self.momentum += 1;
result.ringleader_broken = Some(rl.name.clone());
result.nerve_change = calm;
result.description = format!(
"{} breaks {} — crowd calms as leader falters", action.actor, rl.name
);
} else {
result.description = format!(
"{} rattles {} — they hesitate", action.actor, rl.name
);
}
}
}
}
CrowdActionType::PhysicalHold => {
self.surge_countdown += 1;
result.surge_delayed = true;
result.description = format!(
"{} holds the line — the crowd cannot reach the target yet", action.actor
);
}
CrowdActionType::ShockAction => {
let shock = 5;
self.collective_nerve = (self.collective_nerve + shock).min(self.max_nerve);
self.momentum -= 1; result.nerve_change = shock;
result.momentum_change = -1;
result.description = format!(
"{} fires warning shots — the crowd flinches, but the fear deepens", action.actor
);
}
CrowdActionType::MedicalAuthority => {
let calm = MEDICAL_AUTHORITY_RESTORE;
self.collective_nerve = (self.collective_nerve + calm).min(self.max_nerve);
self.momentum += 1;
result.nerve_change = calm;
result.momentum_change = 1;
result.description = format!(
"{} presents medical evidence — some of the fear loosens", action.actor
);
}
CrowdActionType::Redirect => {
let redirect = REDIRECT_RESTORE;
self.collective_nerve = (self.collective_nerve + redirect).min(self.max_nerve);
result.nerve_change = redirect;
result.description = format!(
"{} redirects the crowd's anger — effective and ugly", action.actor
);
}
CrowdActionType::Rebuke => {
if let Some(target_id) = &action.target {
if let Some(rl) = self.ringleaders.iter_mut().find(|r| r.id == *target_id) {
rl.nerve -= 15; if rl.nerve <= 0 && !rl.broken {
rl.broken = true;
let calm = rl.influence + 3;
self.collective_nerve = (self.collective_nerve + calm).min(self.max_nerve);
self.momentum += 2;
result.ringleader_broken = Some(rl.name.clone());
result.nerve_change = calm;
result.momentum_change = 2;
result.description = format!(
"{} speaks a truth that {} cannot answer — they break", action.actor, rl.name
);
}
}
}
}
}
debug!(
turn = self.turn,
nerve = self.collective_nerve,
momentum = self.momentum,
phase = ?self.phase,
"crowd action executed"
);
result
}
pub fn advance(&mut self) -> CrowdPhase {
self.collective_nerve -= 3;
self.collective_nerve = (self.collective_nerve + self.momentum).max(0).min(self.max_nerve);
if self.surge_countdown > 0 {
self.surge_countdown -= 1;
}
if self.max_nerve == 0 {
self.phase = CrowdPhase::Broken;
self.target_safe = false;
eprintln!(
"[crowd] max_nerve is 0 — this should never happen. \
Treating crowd as broken to avoid division by zero."
);
return self.phase;
}
let nerve_pct = (self.collective_nerve as f32 / self.max_nerve as f32 * 100.0).round() as i32;
self.phase = if self.collective_nerve <= 0 || self.surge_countdown == 0 {
CrowdPhase::Broken
} else if nerve_pct >= 80 {
CrowdPhase::Dispersed
} else if nerve_pct >= 50 || self.momentum > 2 {
CrowdPhase::Calming
} else if nerve_pct < 30 {
CrowdPhase::Surging
} else {
CrowdPhase::Tense
};
if self.phase == CrowdPhase::Broken {
self.target_safe = false;
info!("crowd broke — target at risk");
} else if self.phase == CrowdPhase::Dispersed {
info!("crowd dispersed — containment successful");
}
self.phase
}
pub fn is_resolved(&self) -> bool {
self.phase == CrowdPhase::Dispersed || self.phase == CrowdPhase::Broken
}
pub fn contained(&self) -> bool {
self.phase == CrowdPhase::Dispersed
}
pub fn active_ringleaders(&self) -> usize {
self.ringleaders.iter().filter(|r| !r.broken).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_crowd() -> CrowdState {
CrowdState::new(50, 5, vec![
Ringleader { id: "loud_man".to_string(), name: "Loud Man".to_string(), nerve: 12, influence: 8, broken: false },
Ringleader { id: "angry_woman".to_string(), name: "Angry Woman".to_string(), nerve: 15, influence: 10, broken: false },
])
}
#[test]
fn broad_calm_restores_nerve() {
let mut crowd = test_crowd();
crowd.collective_nerve = 30;
let result = crowd.execute_action(&CrowdAction {
actor: "miriam".to_string(),
action_type: CrowdActionType::BroadCalm,
target: None,
});
assert!(result.nerve_change > 0);
assert!(crowd.collective_nerve > 30);
}
#[test]
fn targeted_nerve_breaks_ringleader() {
let mut crowd = test_crowd();
crowd.execute_action(&CrowdAction {
actor: "eli".to_string(),
action_type: CrowdActionType::TargetedNerve,
target: Some("loud_man".to_string()),
});
let rl = crowd.ringleaders.iter().find(|r| r.id == "loud_man").expect("test ringleader must exist in crowd setup");
assert!(rl.nerve <= 2);
let result = crowd.execute_action(&CrowdAction {
actor: "eli".to_string(),
action_type: CrowdActionType::TargetedNerve,
target: Some("loud_man".to_string()),
});
assert!(result.ringleader_broken.is_some());
assert_eq!(crowd.active_ringleaders(), 1);
}
#[test]
fn physical_hold_delays_surge() {
let mut crowd = test_crowd();
let initial_countdown = crowd.surge_countdown;
let result = crowd.execute_action(&CrowdAction {
actor: "rosa".to_string(),
action_type: CrowdActionType::PhysicalHold,
target: None,
});
assert!(result.surge_delayed);
assert_eq!(crowd.surge_countdown, initial_countdown + 1);
}
#[test]
fn shock_calms_briefly_but_worsens_momentum() {
let mut crowd = test_crowd();
crowd.collective_nerve = 25;
let result = crowd.execute_action(&CrowdAction {
actor: "galen".to_string(),
action_type: CrowdActionType::ShockAction,
target: None,
});
assert!(result.nerve_change > 0, "shock should restore some nerve");
assert!(result.momentum_change < 0, "shock should worsen momentum");
}
#[test]
fn crowd_disperses_when_nerve_high() {
let mut crowd = test_crowd();
crowd.collective_nerve = crowd.max_nerve; crowd.momentum = 5;
let phase = crowd.advance();
assert_eq!(phase, CrowdPhase::Dispersed);
assert!(crowd.contained());
}
#[test]
fn crowd_breaks_when_nerve_zero() {
let mut crowd = test_crowd();
crowd.collective_nerve = 0;
let phase = crowd.advance();
assert_eq!(phase, CrowdPhase::Broken);
assert!(!crowd.target_safe);
assert!(!crowd.contained());
}
#[test]
fn crowd_breaks_when_surge_reaches_zero() {
let mut crowd = test_crowd();
crowd.surge_countdown = 1;
crowd.collective_nerve = 20;
let phase = crowd.advance();
assert_eq!(phase, CrowdPhase::Broken);
}
#[test]
fn different_actions_affect_crowd_differently() {
let mut crowd_a = test_crowd();
let mut crowd_b = test_crowd();
let mut crowd_c = test_crowd();
crowd_a.collective_nerve = 25;
crowd_b.collective_nerve = 25;
crowd_c.collective_nerve = 25;
crowd_a.execute_action(&CrowdAction {
actor: "miriam".to_string(),
action_type: CrowdActionType::BroadCalm,
target: None,
});
crowd_b.execute_action(&CrowdAction {
actor: "galen".to_string(),
action_type: CrowdActionType::ShockAction,
target: None,
});
crowd_c.execute_action(&CrowdAction {
actor: "ada".to_string(),
action_type: CrowdActionType::MedicalAuthority,
target: None,
});
assert!(crowd_a.momentum > crowd_b.momentum,
"Miriam should build better momentum than Galen's shock");
assert!(crowd_b.momentum < 0,
"Galen's shock should produce negative momentum");
}
}