use serde::{Deserialize, Serialize};
use crate::types::*;
use crate::scene::types::StateEffect;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvoyState {
pub members: Vec<ConvoyMember>,
pub assets: Vec<ConvoyAsset>,
pub day: u8,
pub is_night: bool,
pub formation: Option<FormationChoice>,
pub tension: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvoyMember {
pub id: String,
pub name: String,
pub role: ConvoyRole,
pub alive: bool,
pub trust_toward_galen: i32,
pub spoken_to: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConvoyRole {
SecurityLead,
Clerk,
Teamster,
CampHand,
Surveyor,
Irregular,
Crew,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvoyAsset {
pub id: String,
pub name: String,
pub integrity: i32,
pub max_integrity: i32,
pub loss_effects: Vec<StateEffect>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FormationChoice {
WaterCart,
PayrollCoach,
ForwardScout,
RearGuard,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseScript {
pub trigger: PhaseTrigger,
pub narration: String,
pub objective_changes: Vec<ObjectiveChange>,
pub effects: Vec<StateEffect>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PhaseTrigger {
AllEnemiesDown,
Round(u32),
FlagSet(String),
AssetDamaged { asset_id: String, below: i32 },
MemberDied(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ObjectiveChange {
Add { id: String, label: String, is_primary: bool },
Remove(String),
Relabel { id: String, new_label: String },
FlipAlly { member_id: String },
}
impl ConvoyState {
pub fn new_saints_mile_convoy() -> Self {
Self {
members: vec![
ConvoyMember {
id: "bale".to_string(),
name: "Captain Orrin Bale".to_string(),
role: ConvoyRole::SecurityLead,
alive: true, trust_toward_galen: 5, spoken_to: false,
},
ConvoyMember {
id: "hester".to_string(),
name: "Hester Vale".to_string(),
role: ConvoyRole::Clerk,
alive: true, trust_toward_galen: 0, spoken_to: false,
},
ConvoyMember {
id: "tom".to_string(),
name: "Tom Reed".to_string(),
role: ConvoyRole::Teamster,
alive: true, trust_toward_galen: 3, spoken_to: false,
},
ConvoyMember {
id: "nella".to_string(),
name: "Nella Creed".to_string(),
role: ConvoyRole::CampHand,
alive: true, trust_toward_galen: 5, spoken_to: false,
},
ConvoyMember {
id: "cask".to_string(),
name: "Old Cask Fen".to_string(),
role: ConvoyRole::Surveyor,
alive: true, trust_toward_galen: 0, spoken_to: false,
},
ConvoyMember {
id: "eli_convoy".to_string(),
name: "Eli Winter".to_string(),
role: ConvoyRole::Irregular,
alive: true, trust_toward_galen: 0, spoken_to: false,
},
],
assets: vec![
ConvoyAsset {
id: "water_cart".to_string(),
name: "Water Cart".to_string(),
integrity: 100, max_integrity: 100,
loss_effects: vec![
StateEffect::SetFlag {
id: FlagId::new("water_cart_lost"),
value: FlagValue::Bool(true),
},
StateEffect::AdjustResource {
resource: ResourceKind::Water,
delta: -50,
},
],
},
ConvoyAsset {
id: "payroll_coach".to_string(),
name: "Payroll Coach".to_string(),
integrity: 100, max_integrity: 100,
loss_effects: vec![
StateEffect::SetFlag {
id: FlagId::new("payroll_lost"),
value: FlagValue::Bool(true),
},
],
},
ConvoyAsset {
id: "powder_wagon".to_string(),
name: "Powder Wagon".to_string(),
integrity: 80, max_integrity: 80,
loss_effects: vec![
StateEffect::SetFlag {
id: FlagId::new("powder_wagon_exploded"),
value: FlagValue::Bool(true),
},
],
},
ConvoyAsset {
id: "passenger_wagon".to_string(),
name: "Passenger Wagon".to_string(),
integrity: 60, max_integrity: 60,
loss_effects: vec![
StateEffect::SetFlag {
id: FlagId::new("passenger_wagon_lost"),
value: FlagValue::Bool(true),
},
],
},
],
day: 1,
is_night: false,
formation: None,
tension: 0,
}
}
pub fn advance_day(&mut self) {
self.day += 1;
self.is_night = false;
}
pub fn set_night(&mut self) {
self.is_night = true;
}
pub fn member(&self, id: &str) -> Option<&ConvoyMember> {
self.members.iter().find(|m| m.id == id)
}
pub fn member_mut(&mut self, id: &str) -> Option<&mut ConvoyMember> {
self.members.iter_mut().find(|m| m.id == id)
}
pub fn speak_to(&mut self, id: &str) -> bool {
if let Some(m) = self.member_mut(id) {
m.spoken_to = true;
true
} else {
eprintln!(
"[convoy] Attempted to speak to missing convoy member: '{}'. \
Known members: {:?}",
id,
self.members.iter().map(|m| &m.id).collect::<Vec<_>>()
);
false
}
}
pub fn kill_member(&mut self, id: &str) {
if let Some(m) = self.member_mut(id) {
m.alive = false;
}
}
pub fn damage_asset(&mut self, id: &str, amount: i32) -> Vec<StateEffect> {
if let Some(asset) = self.assets.iter_mut().find(|a| a.id == id) {
asset.integrity = (asset.integrity - amount).max(0);
if asset.integrity == 0 {
return asset.loss_effects.clone();
}
}
Vec::new()
}
pub fn asset_integrity(&self, id: &str) -> Option<i32> {
self.assets.iter().find(|a| a.id == id).map(|a| a.integrity)
}
pub fn alive_count(&self) -> usize {
self.members.iter().filter(|m| m.alive).count()
}
pub fn is_alive(&self, id: &str) -> bool {
self.member(id).map_or(false, |m| m.alive)
}
pub fn finalize(&self) -> Vec<StateEffect> {
let mut effects = Vec::new();
for member in &self.members {
effects.push(StateEffect::SetFlag {
id: FlagId::new(format!("convoy_{}_alive", member.id)),
value: FlagValue::Bool(member.alive),
});
}
for asset in &self.assets {
if asset.integrity == 0 {
effects.extend(asset.loss_effects.clone());
}
}
effects
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convoy_creates_with_full_cast() {
let convoy = ConvoyState::new_saints_mile_convoy();
assert_eq!(convoy.members.len(), 6);
assert_eq!(convoy.assets.len(), 4);
assert!(convoy.is_alive("bale"));
assert!(convoy.is_alive("nella"));
assert!(convoy.is_alive("tom"));
assert_eq!(convoy.day, 1);
}
#[test]
fn asset_damage_and_destruction() {
let mut convoy = ConvoyState::new_saints_mile_convoy();
let effects = convoy.damage_asset("water_cart", 60);
assert!(effects.is_empty()); assert_eq!(convoy.asset_integrity("water_cart"), Some(40));
let effects = convoy.damage_asset("water_cart", 50);
assert!(!effects.is_empty()); assert_eq!(convoy.asset_integrity("water_cart"), Some(0));
}
#[test]
fn member_death_and_finalize() {
let mut convoy = ConvoyState::new_saints_mile_convoy();
convoy.kill_member("bale");
assert!(!convoy.is_alive("bale"));
assert_eq!(convoy.alive_count(), 5);
let effects = convoy.finalize();
assert!(effects.iter().any(|e| matches!(e,
StateEffect::SetFlag { id, value: FlagValue::Bool(false) }
if id.0 == "convoy_bale_alive"
)));
assert!(effects.iter().any(|e| matches!(e,
StateEffect::SetFlag { id, value: FlagValue::Bool(true) }
if id.0 == "convoy_nella_alive"
)));
}
#[test]
fn day_night_progression() {
let mut convoy = ConvoyState::new_saints_mile_convoy();
assert_eq!(convoy.day, 1);
assert!(!convoy.is_night);
convoy.set_night();
assert!(convoy.is_night);
convoy.advance_day();
assert_eq!(convoy.day, 2);
assert!(!convoy.is_night);
}
}