Skip to main content

esf_dogma_engine/
fit.rs

1//! The fit to calculate.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6use serde::de::{self, Visitor};
7use serde::{Deserialize, Deserializer, Serialize};
8
9#[cfg(feature = "typescript")]
10use tsify::Tsify;
11
12use crate::projection::Projection;
13
14/// A ship, what is fitted to it, and the character flying it.
15#[cfg_attr(feature = "typescript", derive(Tsify))]
16#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct Fit {
18    /// Only for display; the calculation does not use it.
19    #[cfg_attr(feature = "typescript", tsify(optional))]
20    pub name: Option<String>,
21    /// The ship.
22    pub ship: Ship,
23    /// Modules, drones, fighters, implants, boosters and cargo.
24    pub items: Vec<FitItem>,
25    /// The character flying the ship.
26    #[serde(default)]
27    pub character: Character,
28    /// Where the ship is.
29    #[serde(default)]
30    pub environment: Environment,
31    /// What projections to apply on this fit.
32    #[serde(default, skip_serializing_if = "Projection::is_empty")]
33    pub incoming: Projection,
34}
35
36/// The ship of a fit.
37#[cfg_attr(feature = "typescript", derive(Tsify))]
38#[derive(Serialize, Deserialize, Debug, Clone)]
39pub struct Ship {
40    /// The type id of the ship.
41    pub type_id: i32,
42    /// The type id of the active mode, for ships that have modes.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub mode: Option<i32>,
45}
46
47/// A module, drone, fighter squadron, implant, booster or item in cargo.
48#[cfg_attr(feature = "typescript", derive(Tsify))]
49#[derive(Serialize, Deserialize, Debug, Clone)]
50pub struct FitItem {
51    /// The type id of the item.
52    pub type_id: i32,
53    /// Where the item is.
54    pub slot: Slot,
55    /// 1 for modules. Stack size for drones, fighters and cargo; for fighters
56    /// in a tube, the size of the squadron.
57    #[serde(default = "one")]
58    pub quantity: u32,
59    /// The state asked for; the calculation lowers it when the item cannot
60    /// reach it.
61    pub state: State,
62    /// The charge loaded in the module, if any.
63    #[cfg_attr(feature = "typescript", tsify(optional))]
64    pub charge: Option<Charge>,
65    /// Only for mutated modules and drones.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub mutation: Option<Mutation>,
68    /// Only for fighters: the abilities used, by effect id. `None` uses the
69    /// abilities the fighter uses by default.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub fighter_abilities: Option<BTreeSet<i32>>,
72    /// Only for boosters: the side effects rolled, by effect id. Empty means
73    /// none.
74    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
75    pub booster_side_effects: BTreeSet<i32>,
76    /// How far a module whose bonus grows every cycle has spooled.
77    /// Only per-second stats use it; volley is always unspooled.
78    /// `None` is fully spooled.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub spool: Option<Spool>,
81}
82
83/// Where an item is. The number is the position in its rack, starting at 0;
84/// for implants and boosters, the slot as EVE numbers it, starting at 1.
85#[cfg_attr(feature = "typescript", derive(Tsify))]
86#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)]
87#[serde(tag = "type", content = "index", rename_all = "snake_case")]
88pub enum Slot {
89    /// A high slot.
90    High(u8),
91    /// A medium slot.
92    Medium(u8),
93    /// A low slot.
94    Low(u8),
95    /// A rig slot.
96    Rig(u8),
97    /// A subsystem slot.
98    Subsystem(u8),
99    /// A service slot, on a structure.
100    Service(u8),
101    /// A fighter tube; a squadron that is not offline is in space.
102    FighterTube(u8),
103    /// The fighter bay; nothing in it is in space.
104    FighterBay,
105    /// An implant; the number is its `implantness`.
106    Implant(u8),
107    /// A booster; the number is its `boosterness`.
108    Booster(u16),
109    /// The drone bay; a drone that is not offline is in space.
110    DroneBay,
111    /// The cargo hold; nothing in it is calculated.
112    Cargo,
113}
114
115/// The state of an item, lowest first.
116#[cfg_attr(feature = "typescript", derive(Tsify))]
117#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
118#[serde(rename_all = "snake_case")]
119pub enum State {
120    /// Only passive effects apply.
121    Offline,
122    /// Online effects apply too.
123    Online,
124    /// Active effects apply too.
125    Active,
126    /// Overload effects apply too.
127    Overload,
128}
129
130/// A charge loaded in a module.
131#[cfg_attr(feature = "typescript", derive(Tsify))]
132#[derive(Serialize, Deserialize, Debug, Clone)]
133pub struct Charge {
134    /// The type id of the charge.
135    pub type_id: i32,
136}
137
138/// How a module or drone was mutated.
139#[cfg_attr(feature = "typescript", derive(Tsify))]
140#[derive(Serialize, Deserialize, Debug, Clone)]
141pub struct Mutation {
142    /// The type id of the item before it was mutated.
143    pub base: i32,
144    /// The rolled value of each mutated attribute, by attribute id.
145    #[serde(default, deserialize_with = "id_map")]
146    #[cfg_attr(
147        feature = "typescript",
148        tsify(type = "Map<number, number> | Record<number, number>")
149    )]
150    pub attributes: BTreeMap<i32, f64>,
151}
152
153/// How far a module has spooled.
154#[cfg_attr(feature = "typescript", derive(Tsify))]
155#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
156#[serde(rename_all = "snake_case")]
157pub enum Spool {
158    /// The bonus reached so far: 0.0 is unspooled, 2.125 is +212.5%.
159    MultiplierBonus(f64),
160}
161
162/// The character flying the ship.
163#[cfg_attr(feature = "typescript", derive(Tsify))]
164#[derive(Serialize, Deserialize, Debug, Clone, Default)]
165pub struct Character {
166    /// The level of each skill, by type id. A skill that is not listed is not
167    /// trained, and gives no bonus.
168    #[serde(default, deserialize_with = "id_map")]
169    #[cfg_attr(
170        feature = "typescript",
171        tsify(type = "Map<number, number> | Record<number, number>")
172    )]
173    pub skills: BTreeMap<i32, u8>,
174    /// -10.0 to 5.0. Only a few ships have a bonus that scales with it.
175    #[serde(default)]
176    pub security_status: f64,
177}
178
179/// Where the ship is.
180#[cfg_attr(feature = "typescript", derive(Tsify))]
181#[derive(Serialize, Deserialize, Debug, Clone, Default)]
182pub struct Environment {
183    /// The damage the ship is assumed to take, for effective hitpoints.
184    #[serde(default)]
185    pub damage_profile: DamageProfile,
186    /// The security of the solar system.
187    #[serde(default)]
188    pub security: Security,
189    /// What a Reactive Armor Hardener shifts its resistances towards.
190    #[serde(default)]
191    pub reactive_armor: ReactiveArmor,
192}
193
194/// What a Reactive Armor Hardener shifts its resistances towards.
195///
196/// EVE shows it as a plain 15/15/15/15 hardener, as the client has no damage
197/// to shift it against. `DoNotAdapt` reports the same, and is the default.
198#[cfg_attr(feature = "typescript", derive(Tsify))]
199#[derive(Serialize, Deserialize, Debug, Copy, Clone, Default, PartialEq)]
200#[serde(rename_all = "snake_case")]
201pub enum ReactiveArmor {
202    /// Leave the resistances where they start, the way EVE shows them.
203    #[default]
204    DoNotAdapt,
205    /// Shift towards the damage the ship is already assumed to take.
206    DamageProfile,
207    /// Shift towards damage of its own, which the rest of the fit ignores.
208    Profile(DamageProfile),
209}
210
211/// How incoming damage is split over the four damage types. Only the ratio
212/// matters; the calculation scales the four to add up to one.
213#[cfg_attr(feature = "typescript", derive(Tsify))]
214#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
215pub struct DamageProfile {
216    /// EM damage.
217    #[serde(default)]
218    pub em: f64,
219    /// Explosive damage.
220    #[serde(default)]
221    pub explosive: f64,
222    /// Kinetic damage.
223    #[serde(default)]
224    pub kinetic: f64,
225    /// Thermal damage.
226    #[serde(default)]
227    pub thermal: f64,
228}
229
230impl Default for DamageProfile {
231    fn default() -> Self {
232        Self {
233            em: 0.25,
234            explosive: 0.25,
235            kinetic: 0.25,
236            thermal: 0.25,
237        }
238    }
239}
240
241/// The security of a solar system.
242#[cfg_attr(feature = "typescript", derive(Tsify))]
243#[derive(Serialize, Deserialize, Debug, Copy, Clone, Default, PartialEq, Eq)]
244#[serde(rename_all = "snake_case")]
245pub enum Security {
246    /// High-sec.
247    #[default]
248    HighSec,
249    /// Low-sec.
250    LowSec,
251    /// Null-sec.
252    NullSec,
253    /// Wormhole space.
254    Wormhole,
255}
256
257fn one() -> u32 {
258    1
259}
260
261/* JSON and JavaScript objects only have string keys, Python dicts have real
262 * ones. */
263pub(crate) fn id_map<'de, D, V>(deserializer: D) -> Result<BTreeMap<i32, V>, D::Error>
264where
265    D: Deserializer<'de>,
266    V: Deserialize<'de>,
267{
268    Ok(BTreeMap::<Id, V>::deserialize(deserializer)?
269        .into_iter()
270        .map(|(Id(key), value)| (key, value))
271        .collect())
272}
273
274#[derive(PartialEq, Eq, PartialOrd, Ord)]
275struct Id(i32);
276
277impl<'de> Deserialize<'de> for Id {
278    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Id, D::Error> {
279        struct IdVisitor;
280
281        impl Visitor<'_> for IdVisitor {
282            type Value = Id;
283
284            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
285                formatter.write_str("an identifier")
286            }
287
288            fn visit_str<E: de::Error>(self, value: &str) -> Result<Id, E> {
289                value
290                    .parse()
291                    .map(Id)
292                    .map_err(|_| E::custom(format!("expected an identifier, found {value:?}")))
293            }
294
295            fn visit_i64<E: de::Error>(self, value: i64) -> Result<Id, E> {
296                i32::try_from(value)
297                    .map(Id)
298                    .map_err(|_| E::custom(format!("expected an identifier, found {value}")))
299            }
300
301            fn visit_u64<E: de::Error>(self, value: u64) -> Result<Id, E> {
302                i32::try_from(value)
303                    .map(Id)
304                    .map_err(|_| E::custom(format!("expected an identifier, found {value}")))
305            }
306        }
307
308        deserializer.deserialize_any(IdVisitor)
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::projection::ProjectedBuff;
316
317    #[test]
318    fn minimal_fit_fills_defaults() {
319        let fit: Fit = serde_json::from_str(
320            r#"{
321                "ship": {"type_id": 587},
322                "items": [
323                    {"type_id": 2873, "slot": {"type": "high", "index": 0}, "state": "active"},
324                    {"type_id": 2488, "slot": {"type": "drone_bay"}, "quantity": 5, "state": "active"}
325                ],
326                "character": {"skills": {"3300": 5}}
327            }"#,
328        )
329        .unwrap();
330
331        assert_eq!(fit.ship.mode, None);
332        assert_eq!(fit.items[0].slot, Slot::High(0));
333        assert_eq!(fit.items[0].quantity, 1);
334        assert_eq!(fit.items[0].fighter_abilities, None);
335        assert_eq!(fit.items[0].spool, None);
336        assert_eq!(fit.items[1].slot, Slot::DroneBay);
337        assert_eq!(fit.items[1].quantity, 5);
338        assert_eq!(fit.character.skills[&3300], 5);
339        assert_eq!(fit.character.security_status, 0.0);
340        assert_eq!(fit.environment.security, Security::HighSec);
341        assert_eq!(fit.environment.damage_profile, DamageProfile::default());
342        assert_eq!(fit.environment.reactive_armor, ReactiveArmor::DoNotAdapt);
343        assert!(fit.incoming.is_empty());
344    }
345
346    #[test]
347    fn reads_damage_profile() {
348        let fit: Fit = serde_json::from_str(
349            r#"{
350                "ship": {"type_id": 587},
351                "items": [],
352                "environment": {"damage_profile": {"kinetic": 3, "thermal": 1}}
353            }"#,
354        )
355        .unwrap();
356
357        assert_eq!(
358            fit.environment.damage_profile,
359            DamageProfile {
360                em: 0.0,
361                explosive: 0.0,
362                kinetic: 3.0,
363                thermal: 1.0,
364            }
365        );
366        assert_eq!(fit.environment.security, Security::HighSec);
367    }
368
369    #[test]
370    fn reads_reactive_armor() {
371        let read = |environment| {
372            let fit: Fit = serde_json::from_str(&format!(
373                r#"{{"ship": {{"type_id": 587}}, "items": [], "environment": {environment}}}"#
374            ))
375            .unwrap();
376            fit.environment.reactive_armor
377        };
378
379        assert_eq!(
380            read(r#"{"reactive_armor": "damage_profile"}"#),
381            ReactiveArmor::DamageProfile
382        );
383        assert_eq!(
384            read(r#"{"reactive_armor": {"profile": {"kinetic": 3, "thermal": 1}}}"#),
385            ReactiveArmor::Profile(DamageProfile {
386                em: 0.0,
387                explosive: 0.0,
388                kinetic: 3.0,
389                thermal: 1.0,
390            })
391        );
392    }
393
394    #[test]
395    fn reads_security() {
396        let fit: Fit = serde_json::from_str(
397            r#"{
398                "ship": {"type_id": 35832},
399                "items": [],
400                "environment": {"security": "null_sec"}
401            }"#,
402        )
403        .unwrap();
404
405        assert_eq!(fit.environment.security, Security::NullSec);
406    }
407
408    #[test]
409    fn reads_incoming() {
410        let fit: Fit = serde_json::from_str(
411            r#"{
412                "ship": {"type_id": 587},
413                "items": [],
414                "incoming": {
415                    "buffs": [{"id": 10, "value": -8.0}],
416                    "effects": [{"type_id": 527, "effect_id": 6426, "attributes": {"20": -60.0}}]
417                }
418            }"#,
419        )
420        .unwrap();
421
422        assert_eq!(
423            fit.incoming.buffs,
424            [ProjectedBuff {
425                id: 10,
426                value: -8.0
427            }]
428        );
429        assert_eq!(fit.incoming.effects[0].type_id, 527);
430        assert_eq!(fit.incoming.effects[0].effect_id, 6426);
431        assert_eq!(
432            fit.incoming.effects[0].attributes,
433            BTreeMap::from([(20, -60.0)])
434        );
435    }
436
437    #[test]
438    fn reads_fighters() {
439        let fit: Fit = serde_json::from_str(
440            r#"{
441                "ship": {"type_id": 23911},
442                "items": [
443                    {"type_id": 40556, "slot": {"type": "fighter_tube", "index": 1}, "quantity": 6, "state": "active", "fighter_abilities": [6465, 6431]},
444                    {"type_id": 40556, "slot": {"type": "fighter_bay"}, "quantity": 3, "state": "offline", "fighter_abilities": []}
445                ]
446            }"#,
447        )
448        .unwrap();
449
450        assert_eq!(fit.items[0].slot, Slot::FighterTube(1));
451        assert_eq!(
452            fit.items[0].fighter_abilities,
453            Some(BTreeSet::from([6431, 6465]))
454        );
455        assert_eq!(fit.items[1].slot, Slot::FighterBay);
456        assert_eq!(fit.items[1].fighter_abilities, Some(BTreeSet::new()));
457    }
458
459    #[test]
460    fn reads_implants_and_boosters() {
461        let fit: Fit = serde_json::from_str(
462            r#"{
463                "ship": {"type_id": 587},
464                "items": [
465                    {"type_id": 20499, "slot": {"type": "implant", "index": 1}, "state": "online"},
466                    {"type_id": 9950, "slot": {"type": "booster", "index": 1}, "state": "online", "booster_side_effects": [2745, 2737]},
467                    {"type_id": 57285, "slot": {"type": "booster", "index": 504}, "state": "online"}
468                ]
469            }"#,
470        )
471        .unwrap();
472
473        assert_eq!(fit.items[0].slot, Slot::Implant(1));
474        assert_eq!(fit.items[1].slot, Slot::Booster(1));
475        assert_eq!(
476            fit.items[1].booster_side_effects,
477            BTreeSet::from([2737, 2745])
478        );
479        assert_eq!(fit.items[2].slot, Slot::Booster(504));
480        assert!(fit.items[2].booster_side_effects.is_empty());
481    }
482
483    #[test]
484    fn reads_mutation() {
485        let fit: Fit = serde_json::from_str(
486            r#"{
487                "ship": {"type_id": 587},
488                "items": [
489                    {"type_id": 47732, "slot": {"type": "medium", "index": 0}, "state": "active", "mutation": {"base": 448, "attributes": {"6": 7.5, "54": 10500}}}
490                ]
491            }"#,
492        )
493        .unwrap();
494
495        let mutation = fit.items[0].mutation.as_ref().unwrap();
496        assert_eq!(mutation.base, 448);
497        assert_eq!(
498            mutation.attributes,
499            BTreeMap::from([(6, 7.5), (54, 10500.0)])
500        );
501    }
502
503    #[test]
504    fn reads_spool() {
505        let fit: Fit = serde_json::from_str(
506            r#"{
507                "ship": {"type_id": 52250},
508                "items": [
509                    {"type_id": 47914, "slot": {"type": "high", "index": 0}, "state": "active", "spool": {"multiplier_bonus": 0.7}}
510                ]
511            }"#,
512        )
513        .unwrap();
514
515        assert_eq!(fit.items[0].spool, Some(Spool::MultiplierBonus(0.7)));
516    }
517
518    #[test]
519    fn reads_security_status() {
520        let fit: Fit = serde_json::from_str(
521            r#"{
522                "ship": {"type_id": 44995},
523                "items": [],
524                "character": {"security_status": -2.5}
525            }"#,
526        )
527        .unwrap();
528
529        assert_eq!(fit.character.security_status, -2.5);
530        assert!(fit.character.skills.is_empty());
531    }
532
533    #[test]
534    fn reads_mode() {
535        let fit: Fit = serde_json::from_str(
536            r#"{
537                "ship": {"type_id": 34317, "mode": 34319},
538                "items": []
539            }"#,
540        )
541        .unwrap();
542
543        assert_eq!(fit.ship.type_id, 34317);
544        assert_eq!(fit.ship.mode, Some(34319));
545    }
546
547    #[test]
548    fn round_trips_through_json() {
549        let fit = Fit {
550            name: Some("Rifter".to_string()),
551            ship: Ship {
552                type_id: 587,
553                mode: None,
554            },
555            items: vec![FitItem {
556                type_id: 47408,
557                slot: Slot::Medium(2),
558                quantity: 1,
559                state: State::Overload,
560                charge: None,
561                mutation: None,
562                fighter_abilities: None,
563                booster_side_effects: BTreeSet::new(),
564                spool: None,
565            }],
566            character: Character::default(),
567            environment: Environment::default(),
568            incoming: Projection::default(),
569        };
570
571        let json = serde_json::to_string(&fit).unwrap();
572        let parsed: Fit = serde_json::from_str(&json).unwrap();
573
574        assert!(!json.contains("fighter_abilities"));
575        assert!(!json.contains("booster_side_effects"));
576        assert!(!json.contains("spool"));
577        assert!(!json.contains("mutation"));
578        assert!(!json.contains("mode"));
579        assert!(!json.contains("incoming"));
580        assert_eq!(parsed.items[0].slot, Slot::Medium(2));
581        assert_eq!(parsed.items[0].state, State::Overload);
582    }
583}