use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Fit {
pub name: Option<String>,
pub ship: Ship,
pub items: Vec<FitItem>,
#[serde(default)]
pub character: Character,
#[serde(default)]
pub environment: Environment,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Ship {
pub type_id: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<i32>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FitItem {
pub type_id: i32,
pub slot: Slot,
#[serde(default = "one")]
pub quantity: u32,
pub state: State,
pub charge: Option<Charge>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mutation: Option<Mutation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fighter_abilities: Option<BTreeSet<i32>>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub booster_side_effects: BTreeSet<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spool: Option<Spool>,
}
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)]
#[serde(tag = "type", content = "index", rename_all = "snake_case")]
pub enum Slot {
High(u8),
Medium(u8),
Low(u8),
Rig(u8),
Subsystem(u8),
Service(u8),
FighterTube(u8),
FighterBay,
Implant(u8),
Booster(u16),
DroneBay,
Cargo,
}
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum State {
Offline,
Online,
Active,
Overload,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Charge {
pub type_id: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Mutation {
pub base: i32,
#[serde(default, deserialize_with = "id_map")]
pub attributes: BTreeMap<i32, f64>,
}
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Spool {
MultiplierBonus(f64),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Character {
#[serde(default, deserialize_with = "id_map")]
pub skills: BTreeMap<i32, u8>,
#[serde(default)]
pub security_status: f64,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Environment {
#[serde(default)]
pub damage_profile: DamageProfile,
#[serde(default)]
pub security: Security,
}
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
pub struct DamageProfile {
#[serde(default)]
pub em: f64,
#[serde(default)]
pub explosive: f64,
#[serde(default)]
pub kinetic: f64,
#[serde(default)]
pub thermal: f64,
}
impl Default for DamageProfile {
fn default() -> Self {
Self {
em: 0.25,
explosive: 0.25,
kinetic: 0.25,
thermal: 0.25,
}
}
}
#[derive(Serialize, Deserialize, Debug, Copy, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Security {
#[default]
HighSec,
LowSec,
NullSec,
Wormhole,
}
fn one() -> u32 {
1
}
fn id_map<'de, D, V>(deserializer: D) -> Result<BTreeMap<i32, V>, D::Error>
where
D: Deserializer<'de>,
V: Deserialize<'de>,
{
BTreeMap::<String, V>::deserialize(deserializer)?
.into_iter()
.map(|(key, value)| {
let key = key.parse().map_err(|_| {
serde::de::Error::custom(format!("expected an identifier, found {key:?}"))
})?;
Ok((key, value))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minimal_fit_fills_defaults() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 587},
"items": [
{"type_id": 2873, "slot": {"type": "high", "index": 0}, "state": "active"},
{"type_id": 2488, "slot": {"type": "drone_bay"}, "quantity": 5, "state": "active"}
],
"character": {"skills": {"3300": 5}}
}"#,
)
.unwrap();
assert_eq!(fit.ship.mode, None);
assert_eq!(fit.items[0].slot, Slot::High(0));
assert_eq!(fit.items[0].quantity, 1);
assert_eq!(fit.items[0].fighter_abilities, None);
assert_eq!(fit.items[0].spool, None);
assert_eq!(fit.items[1].slot, Slot::DroneBay);
assert_eq!(fit.items[1].quantity, 5);
assert_eq!(fit.character.skills[&3300], 5);
assert_eq!(fit.character.security_status, 0.0);
assert_eq!(fit.environment.security, Security::HighSec);
assert_eq!(fit.environment.damage_profile, DamageProfile::default());
}
#[test]
fn reads_damage_profile() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 587},
"items": [],
"environment": {"damage_profile": {"kinetic": 3, "thermal": 1}}
}"#,
)
.unwrap();
assert_eq!(
fit.environment.damage_profile,
DamageProfile {
em: 0.0,
explosive: 0.0,
kinetic: 3.0,
thermal: 1.0,
}
);
assert_eq!(fit.environment.security, Security::HighSec);
}
#[test]
fn reads_security() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 35832},
"items": [],
"environment": {"security": "null_sec"}
}"#,
)
.unwrap();
assert_eq!(fit.environment.security, Security::NullSec);
}
#[test]
fn reads_fighters() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 23911},
"items": [
{"type_id": 40556, "slot": {"type": "fighter_tube", "index": 1}, "quantity": 6, "state": "active", "fighter_abilities": [6465, 6431]},
{"type_id": 40556, "slot": {"type": "fighter_bay"}, "quantity": 3, "state": "offline", "fighter_abilities": []}
]
}"#,
)
.unwrap();
assert_eq!(fit.items[0].slot, Slot::FighterTube(1));
assert_eq!(
fit.items[0].fighter_abilities,
Some(BTreeSet::from([6431, 6465]))
);
assert_eq!(fit.items[1].slot, Slot::FighterBay);
assert_eq!(fit.items[1].fighter_abilities, Some(BTreeSet::new()));
}
#[test]
fn reads_implants_and_boosters() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 587},
"items": [
{"type_id": 20499, "slot": {"type": "implant", "index": 1}, "state": "online"},
{"type_id": 9950, "slot": {"type": "booster", "index": 1}, "state": "online", "booster_side_effects": [2745, 2737]},
{"type_id": 57285, "slot": {"type": "booster", "index": 504}, "state": "online"}
]
}"#,
)
.unwrap();
assert_eq!(fit.items[0].slot, Slot::Implant(1));
assert_eq!(fit.items[1].slot, Slot::Booster(1));
assert_eq!(
fit.items[1].booster_side_effects,
BTreeSet::from([2737, 2745])
);
assert_eq!(fit.items[2].slot, Slot::Booster(504));
assert!(fit.items[2].booster_side_effects.is_empty());
}
#[test]
fn reads_mutation() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 587},
"items": [
{"type_id": 47732, "slot": {"type": "medium", "index": 0}, "state": "active", "mutation": {"base": 448, "attributes": {"6": 7.5, "54": 10500}}}
]
}"#,
)
.unwrap();
let mutation = fit.items[0].mutation.as_ref().unwrap();
assert_eq!(mutation.base, 448);
assert_eq!(
mutation.attributes,
BTreeMap::from([(6, 7.5), (54, 10500.0)])
);
}
#[test]
fn reads_spool() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 52250},
"items": [
{"type_id": 47914, "slot": {"type": "high", "index": 0}, "state": "active", "spool": {"multiplier_bonus": 0.7}}
]
}"#,
)
.unwrap();
assert_eq!(fit.items[0].spool, Some(Spool::MultiplierBonus(0.7)));
}
#[test]
fn reads_security_status() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 44995},
"items": [],
"character": {"security_status": -2.5}
}"#,
)
.unwrap();
assert_eq!(fit.character.security_status, -2.5);
assert!(fit.character.skills.is_empty());
}
#[test]
fn reads_mode() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 34317, "mode": 34319},
"items": []
}"#,
)
.unwrap();
assert_eq!(fit.ship.type_id, 34317);
assert_eq!(fit.ship.mode, Some(34319));
}
#[test]
fn round_trips_through_json() {
let fit = Fit {
name: Some("Rifter".to_string()),
ship: Ship {
type_id: 587,
mode: None,
},
items: vec![FitItem {
type_id: 47408,
slot: Slot::Medium(2),
quantity: 1,
state: State::Overload,
charge: None,
mutation: None,
fighter_abilities: None,
booster_side_effects: BTreeSet::new(),
spool: None,
}],
character: Character::default(),
environment: Environment::default(),
};
let json = serde_json::to_string(&fit).unwrap();
let parsed: Fit = serde_json::from_str(&json).unwrap();
assert!(!json.contains("fighter_abilities"));
assert!(!json.contains("booster_side_effects"));
assert!(!json.contains("spool"));
assert!(!json.contains("mutation"));
assert!(!json.contains("mode"));
assert_eq!(parsed.items[0].slot, Slot::Medium(2));
assert_eq!(parsed.items[0].state, State::Overload);
}
}