use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
#[cfg(feature = "typescript")]
use tsify::Tsify;
use crate::projection::Projection;
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Fit {
#[cfg_attr(feature = "typescript", tsify(optional))]
pub name: Option<String>,
pub ship: Ship,
pub items: Vec<FitItem>,
#[serde(default)]
pub character: Character,
#[serde(default)]
pub environment: Environment,
#[serde(default, skip_serializing_if = "Projection::is_empty")]
pub incoming: Projection,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Ship {
pub type_id: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<i32>,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FitItem {
pub type_id: i32,
pub slot: Slot,
#[serde(default = "one")]
pub quantity: u32,
pub state: State,
#[cfg_attr(feature = "typescript", tsify(optional))]
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>,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[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,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum State {
Offline,
Online,
Active,
Overload,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Charge {
pub type_id: i32,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Mutation {
pub base: i32,
#[serde(default, deserialize_with = "id_map")]
#[cfg_attr(
feature = "typescript",
tsify(type = "Map<number, number> | Record<number, number>")
)]
pub attributes: BTreeMap<i32, f64>,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Spool {
MultiplierBonus(f64),
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Character {
#[serde(default, deserialize_with = "id_map")]
#[cfg_attr(
feature = "typescript",
tsify(type = "Map<number, number> | Record<number, number>")
)]
pub skills: BTreeMap<i32, u8>,
#[serde(default)]
pub security_status: f64,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Environment {
#[serde(default)]
pub damage_profile: DamageProfile,
#[serde(default)]
pub security: Security,
#[serde(default)]
pub reactive_armor: ReactiveArmor,
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[derive(Serialize, Deserialize, Debug, Copy, Clone, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ReactiveArmor {
#[default]
DoNotAdapt,
DamageProfile,
Profile(DamageProfile),
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[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,
}
}
}
#[cfg_attr(feature = "typescript", derive(Tsify))]
#[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
}
pub(crate) fn id_map<'de, D, V>(deserializer: D) -> Result<BTreeMap<i32, V>, D::Error>
where
D: Deserializer<'de>,
V: Deserialize<'de>,
{
Ok(BTreeMap::<Id, V>::deserialize(deserializer)?
.into_iter()
.map(|(Id(key), value)| (key, value))
.collect())
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct Id(i32);
impl<'de> Deserialize<'de> for Id {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Id, D::Error> {
struct IdVisitor;
impl Visitor<'_> for IdVisitor {
type Value = Id;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("an identifier")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Id, E> {
value
.parse()
.map(Id)
.map_err(|_| E::custom(format!("expected an identifier, found {value:?}")))
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<Id, E> {
i32::try_from(value)
.map(Id)
.map_err(|_| E::custom(format!("expected an identifier, found {value}")))
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Id, E> {
i32::try_from(value)
.map(Id)
.map_err(|_| E::custom(format!("expected an identifier, found {value}")))
}
}
deserializer.deserialize_any(IdVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::projection::ProjectedBuff;
#[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());
assert_eq!(fit.environment.reactive_armor, ReactiveArmor::DoNotAdapt);
assert!(fit.incoming.is_empty());
}
#[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_reactive_armor() {
let read = |environment| {
let fit: Fit = serde_json::from_str(&format!(
r#"{{"ship": {{"type_id": 587}}, "items": [], "environment": {environment}}}"#
))
.unwrap();
fit.environment.reactive_armor
};
assert_eq!(
read(r#"{"reactive_armor": "damage_profile"}"#),
ReactiveArmor::DamageProfile
);
assert_eq!(
read(r#"{"reactive_armor": {"profile": {"kinetic": 3, "thermal": 1}}}"#),
ReactiveArmor::Profile(DamageProfile {
em: 0.0,
explosive: 0.0,
kinetic: 3.0,
thermal: 1.0,
})
);
}
#[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_incoming() {
let fit: Fit = serde_json::from_str(
r#"{
"ship": {"type_id": 587},
"items": [],
"incoming": {
"buffs": [{"id": 10, "value": -8.0}],
"effects": [{"type_id": 527, "effect_id": 6426, "attributes": {"20": -60.0}}]
}
}"#,
)
.unwrap();
assert_eq!(
fit.incoming.buffs,
[ProjectedBuff {
id: 10,
value: -8.0
}]
);
assert_eq!(fit.incoming.effects[0].type_id, 527);
assert_eq!(fit.incoming.effects[0].effect_id, 6426);
assert_eq!(
fit.incoming.effects[0].attributes,
BTreeMap::from([(20, -60.0)])
);
}
#[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(),
incoming: Projection::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!(!json.contains("incoming"));
assert_eq!(parsed.items[0].slot, Slot::Medium(2));
assert_eq!(parsed.items[0].state, State::Overload);
}
}